refactor(ui): remove deprecated me page components
Delete unused components: - ActivityTimeline, ContributionHeatmap, CreateProjectModal - FollowerCardList, MeSidebar, NotificationList - ProfileHeader, ProjectList, RepoList, UserCardList - LoadingSpinner (replaced by LoadingState)
This commit is contained in:
parent
5827d561db
commit
f6f69a063e
@ -1,138 +0,0 @@
|
|||||||
import { memo } from "react"
|
|
||||||
import { formatDistanceToNow } from "date-fns"
|
|
||||||
import {
|
|
||||||
History,
|
|
||||||
LogIn,
|
|
||||||
LogOut,
|
|
||||||
UserPlus,
|
|
||||||
ShieldCheck,
|
|
||||||
Key,
|
|
||||||
FolderPlus,
|
|
||||||
GitCommit,
|
|
||||||
GitPullRequest,
|
|
||||||
AlertCircle,
|
|
||||||
Settings,
|
|
||||||
Image as ImageIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import type { UserActivityItem } from "@/client/model"
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from "@/components/ui/empty"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import type { ComponentType, SVGProps } from "react"
|
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
|
||||||
items: UserActivityItem[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICON_MAP: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
|
|
||||||
login: LogIn,
|
|
||||||
logout: LogOut,
|
|
||||||
register: UserPlus,
|
|
||||||
password_change: ShieldCheck,
|
|
||||||
ssh_key_add: Key,
|
|
||||||
project_create: FolderPlus,
|
|
||||||
commit: GitCommit,
|
|
||||||
pull_request_create: GitPullRequest,
|
|
||||||
issue_create: AlertCircle,
|
|
||||||
profile_update: Settings,
|
|
||||||
avatar_upload: ImageIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActivityTimeline = memo(function ActivityTimeline({
|
|
||||||
items,
|
|
||||||
isLoading,
|
|
||||||
}: ActivityTimelineProps) {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 py-3">
|
|
||||||
<Skeleton className="mt-0.5 size-7 shrink-0 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
<Skeleton className="h-2.5 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<History />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>No recent activity</EmptyTitle>
|
|
||||||
<EmptyDescription>暂无最近动态</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const Icon = ICON_MAP[item.action] || History
|
|
||||||
return (
|
|
||||||
<div key={item.id}>
|
|
||||||
<div className="flex items-start gap-3 py-3">
|
|
||||||
<div
|
|
||||||
className="flex size-7 shrink-0 items-center justify-center rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent-bg)",
|
|
||||||
color: "var(--accent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon className="size-3.5" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
className="text-[12px] leading-relaxed"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ color: "var(--text-primary)", fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
{item.resource_name && (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
in{" "}
|
|
||||||
<span
|
|
||||||
className="font-semibold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{item.resource_name}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<time
|
|
||||||
className="mt-1 text-[11px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
dateTime={item.created_at}
|
|
||||||
>
|
|
||||||
{formatDistanceToNow(new Date(item.created_at), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{index < items.length - 1 && <Separator className="opacity-60" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { format, parseISO, eachDayOfInterval, isSameDay } from "date-fns";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import type { ContributionHeatmapResponse } from "@/client/model";
|
|
||||||
import { t } from "@/i18n/T";
|
|
||||||
|
|
||||||
interface ContributionHeatmapProps {
|
|
||||||
data: ContributionHeatmapResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContributionHeatmap({ data }: ContributionHeatmapProps) {
|
|
||||||
const { heatmap, total_contributions, start_date, end_date } = data;
|
|
||||||
|
|
||||||
const days = useMemo(() => {
|
|
||||||
const start = parseISO(start_date);
|
|
||||||
const end = parseISO(end_date);
|
|
||||||
return eachDayOfInterval({ start, end });
|
|
||||||
}, [start_date, end_date]);
|
|
||||||
|
|
||||||
const getColor = (count: number) => {
|
|
||||||
if (count === 0) return { bg: "var(--heatmap-0)", darkBg: "var(--heatmap-0)" };
|
|
||||||
if (count < 3) return { bg: "var(--heatmap-1)", darkBg: "var(--heatmap-1)" };
|
|
||||||
if (count < 6) return { bg: "var(--heatmap-2)", darkBg: "var(--heatmap-2)" };
|
|
||||||
if (count < 10) return { bg: "var(--heatmap-3)", darkBg: "var(--heatmap-3)" };
|
|
||||||
return { bg: "var(--heatmap-4)", darkBg: "var(--heatmap-4)" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group days by weeks (Sunday to Saturday)
|
|
||||||
const weeks = useMemo(() => {
|
|
||||||
const result: Date[][] = [];
|
|
||||||
let currentWeek: Date[] = [];
|
|
||||||
|
|
||||||
// Pad the first week if it doesn't start on Sunday
|
|
||||||
const firstDay = days[0];
|
|
||||||
const firstDayOfWeek = firstDay.getDay(); // 0 is Sunday
|
|
||||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
||||||
// currentWeek.push(subDays(firstDay, firstDayOfWeek - i)); // Using null for padding
|
|
||||||
}
|
|
||||||
|
|
||||||
days.forEach((day) => {
|
|
||||||
if (day.getDay() === 0 && currentWeek.length > 0) {
|
|
||||||
result.push(currentWeek);
|
|
||||||
currentWeek = [];
|
|
||||||
}
|
|
||||||
currentWeek.push(day);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentWeek.length > 0) {
|
|
||||||
result.push(currentWeek);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg p-6" style={{ backgroundColor: "var(--surface-elevated)" }}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{t("me.profile.contributions_in_year", { count: total_contributions })}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="inline-flex flex-col">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<div className="flex flex-col gap-1 text-[10px] pr-2 pt-4" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<span>Mon</span>
|
|
||||||
<span className="invisible">Tue</span>
|
|
||||||
<span>Wed</span>
|
|
||||||
<span className="invisible">Thu</span>
|
|
||||||
<span>Fri</span>
|
|
||||||
<span className="invisible">Sat</span>
|
|
||||||
<span className="invisible">Sun</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{weeks.map((week, weekIdx) => (
|
|
||||||
<div key={weekIdx} className="flex flex-col gap-1">
|
|
||||||
{/* Month labels would go here */}
|
|
||||||
{Array.from({ length: 7 }).map((_, dayIdx) => {
|
|
||||||
const day = week.find(d => d.getDay() === (dayIdx + 1) % 7);
|
|
||||||
if (!day) return <div key={dayIdx} className="w-3 h-3 rounded-sm bg-transparent" />;
|
|
||||||
|
|
||||||
const entry = heatmap.find(h => isSameDay(parseISO(h.date), day));
|
|
||||||
const count = entry?.count ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider key={dayIdx}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: getColor(count).bg }}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-xs">
|
|
||||||
{t("me.profile.contributions_on_date", { count, date: format(day, "MMM d, yyyy") })}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 mt-4 text-[10px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<span>{t("me.profile.less")}</span>
|
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "var(--heatmap-0)" }} />
|
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "var(--heatmap-1)" }} />
|
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "var(--heatmap-2)" }} />
|
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "var(--heatmap-3)" }} />
|
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: "var(--heatmap-4)" }} />
|
|
||||||
<span>{t("me.profile.more")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useCreateProjectMutation } from "@/hooks/useProjectsQuery";
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
AtSign,
|
|
||||||
Type,
|
|
||||||
AlignLeft,
|
|
||||||
Globe,
|
|
||||||
Loader2,
|
|
||||||
Rocket,
|
|
||||||
ShieldCheck,
|
|
||||||
AlertCircle
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { t } from "@/i18n/T";
|
|
||||||
|
|
||||||
interface CreateProjectModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const createMutation = useCreateProjectMutation();
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
name: "",
|
|
||||||
display_name: "",
|
|
||||||
description: "",
|
|
||||||
is_public: true
|
|
||||||
});
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!form.name.trim() || !form.display_name.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const res = await createMutation.mutateAsync({
|
|
||||||
name: form.name.trim(),
|
|
||||||
display_name: form.display_name.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
is_public: form.is_public
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
if (res?.project) {
|
|
||||||
navigate(`/${res.project.name}/repos`);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const apiError = err as { response?: { data?: { message?: string } } };
|
|
||||||
setError(apiError.response?.data?.message || t("me.create_project.create_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl shadow-2xl w-full max-w-[560px] overflow-hidden animate-in zoom-in-95 duration-200"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)", borderWidth: "1px" }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-6 py-5 flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderBottomColor: "var(--border-default)", borderBottomWidth: "1px", borderBottomStyle: "solid" }}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)", boxShadow: "0 4px 14px var(--accent)" }}>
|
|
||||||
<Rocket className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-[17px] font-bold" style={{ color: "var(--text-primary)" }}>{t("me.create_project.title")}</h2>
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>{t("me.create_project.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 rounded-full transition-colors"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--surface-elevated)"}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "transparent"}
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="p-8 space-y-6">
|
|
||||||
{/* Project Slug */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-[12px] font-bold uppercase tracking-wider flex items-center gap-2" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<AtSign className="w-3.5 h-3.5" /> {t("me.create_project.slug")}
|
|
||||||
</Label>
|
|
||||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full uppercase" style={{ color: "var(--accent)", backgroundColor: "color-mix(in srgb, var(--accent) 10%, transparent)" }}>{t("me.create_project.slug_required")}</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
placeholder="e.g. my-awesome-team"
|
|
||||||
value={form.name}
|
|
||||||
onChange={e => setForm({...form, name: e.target.value.toLowerCase().replace(/\s+/g, '-')})}
|
|
||||||
className="h-11 text-[15px] font-mono"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", border: "none", colorScheme: "dark" }}
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] px-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("me.create_project.slug_hint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-[12px] font-bold uppercase tracking-wider flex items-center gap-2" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<Type className="w-3.5 h-3.5" /> {t("me.create_project.display_name")}
|
|
||||||
</Label>
|
|
||||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full uppercase" style={{ color: "var(--accent)", backgroundColor: "color-mix(in srgb, var(--accent) 10%, transparent)" }}>{t("me.create_project.display_name_required")}</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. Acme Corporation"
|
|
||||||
value={form.display_name}
|
|
||||||
onChange={e => setForm({...form, display_name: e.target.value})}
|
|
||||||
className="h-11 text-[15px]"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", border: "none", colorScheme: "dark" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[12px] font-bold uppercase tracking-wider flex items-center gap-2" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<AlignLeft className="w-3.5 h-3.5" /> {t("me.create_project.description")}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder={t("me.create_project.desc_placeholder")}
|
|
||||||
value={form.description}
|
|
||||||
onChange={e => setForm({...form, description: e.target.value})}
|
|
||||||
className="min-h-[100px] text-[14px] resize-none py-3"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", border: "none", colorScheme: "dark" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visibility Toggle */}
|
|
||||||
<div className="p-4 rounded-xl flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)", borderWidth: "1px", borderStyle: "solid" }}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${form.is_public ? '' : ''}`} style={form.is_public ? { backgroundColor: "color-mix(in srgb, var(--success) 10%, transparent)", color: "var(--success)" } : { backgroundColor: "color-mix(in srgb, var(--warning) 10%, transparent)", color: "var(--warning)" }}>
|
|
||||||
{form.is_public ? <Globe className="w-5 h-5" /> : <ShieldCheck className="w-5 h-5" />}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[14px] font-bold" style={{ color: "var(--text-primary)" }}>{form.is_public ? t("me.create_project.public_project") : t("me.create_project.private_project")}</h4>
|
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{form.is_public ? t("me.create_project.public_desc") : t("me.create_project.private_desc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setForm({...form, is_public: !form.is_public})}
|
|
||||||
className="font-bold text-[12px]"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--accent) 10%, transparent)"}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "transparent"}
|
|
||||||
>
|
|
||||||
{t("me.create_project.change")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 rounded-lg flex items-start gap-3" style={{ backgroundColor: "color-mix(in srgb, var(--destructive) 10%, transparent)", borderColor: "color-mix(in srgb, var(--destructive) 20%, transparent)", borderWidth: "1px", borderStyle: "solid" }}>
|
|
||||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" style={{ color: "var(--destructive)" }} />
|
|
||||||
<p className="text-[12px] font-medium leading-relaxed" style={{ color: "var(--destructive)" }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-8 py-5 flex items-center justify-end gap-3" style={{ backgroundColor: "var(--surface-elevated)", borderTopColor: "var(--border-default)", borderTopWidth: "1px", borderTopStyle: "solid" }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onClose}
|
|
||||||
className="font-medium hover:underline px-6"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("me.create_project.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!form.name.trim() || !form.display_name.trim() || createMutation.isPending}
|
|
||||||
className="font-bold px-8 h-11 shadow-lg"
|
|
||||||
style={{ backgroundColor: "var(--accent)", boxShadow: "0 4px 14px var(--accent)" }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--accent) 85%, black)"}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--accent)"}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : null}
|
|
||||||
{t("me.create_project.create")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import type { SubscriptionInfo } from "@/client/model";
|
|
||||||
|
|
||||||
interface FollowerCardListProps {
|
|
||||||
users: SubscriptionInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FollowerCardList({ users }: FollowerCardListProps) {
|
|
||||||
if (users.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<p>No followers yet</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{users.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.user_uid}
|
|
||||||
className="flex items-center gap-3 p-4 rounded-lg border transition-colors"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
|
|
||||||
>
|
|
||||||
<Avatar className="w-12 h-12 rounded-lg">
|
|
||||||
<AvatarFallback className="rounded-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
|
|
||||||
U
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-semibold truncate" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{user.user_uid.slice(0, 8)}...
|
|
||||||
</p>
|
|
||||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{new Date(user.subscribed_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Book,
|
|
||||||
Layout,
|
|
||||||
Box,
|
|
||||||
Activity,
|
|
||||||
Star,
|
|
||||||
Users,
|
|
||||||
MessageSquare,
|
|
||||||
Bell,
|
|
||||||
Mail,
|
|
||||||
PanelLeftClose
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ComponentType, SVGProps } from "react";
|
|
||||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
|
||||||
import { useUserInfoQuery, useUserStarsQuery, useUserFollowerCountQuery, useUserFollowingCountQuery, useUserSummaryQuery } from "@/hooks/useUserQuery";
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
|
||||||
end?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ME_NAV_ITEMS: NavItem[] = [
|
|
||||||
{
|
|
||||||
path: "/me",
|
|
||||||
name: "Overview",
|
|
||||||
icon: Book,
|
|
||||||
end: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/repositories",
|
|
||||||
name: "Repositories",
|
|
||||||
icon: Layout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/projects",
|
|
||||||
name: "Projects",
|
|
||||||
icon: Box,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/activity",
|
|
||||||
name: "Activity",
|
|
||||||
icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/chat",
|
|
||||||
name: "Chat",
|
|
||||||
icon: MessageSquare,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/notify",
|
|
||||||
name: "Notifications",
|
|
||||||
icon: Bell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/invitations",
|
|
||||||
name: "Invitations",
|
|
||||||
icon: Mail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/stars",
|
|
||||||
name: "Stars",
|
|
||||||
icon: Star,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/following",
|
|
||||||
name: "Following",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/me/followers",
|
|
||||||
name: "Followers",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MeSidebarProps {
|
|
||||||
onCollapse?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MeSidebar({ onCollapse }: MeSidebarProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const { data: currentUser } = useCurrentUserQuery();
|
|
||||||
const username = currentUser?.username || "";
|
|
||||||
|
|
||||||
const { data: userInfo } = useUserInfoQuery(username);
|
|
||||||
const { data: starsData } = useUserStarsQuery(username);
|
|
||||||
const { data: followerCount } = useUserFollowerCountQuery(username);
|
|
||||||
const { data: followingCount } = useUserFollowingCountQuery(username);
|
|
||||||
const { data: summary } = useUserSummaryQuery(username);
|
|
||||||
|
|
||||||
const isActive = (path: string, end?: boolean) => {
|
|
||||||
if (end) return location.pathname === path;
|
|
||||||
return location.pathname.startsWith(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCount = (name: string) => {
|
|
||||||
const info = summary?.info || userInfo;
|
|
||||||
if (!info) return null;
|
|
||||||
switch (name) {
|
|
||||||
case "Repositories": return info.total_repos;
|
|
||||||
case "Projects": return info.total_projects;
|
|
||||||
case "Stars": return summary?.stars_count ?? starsData?.total;
|
|
||||||
case "Followers": return summary?.follower_count ?? followerCount;
|
|
||||||
case "Following": return summary?.following_count ?? followingCount;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col h-full border-r shrink-0"
|
|
||||||
style={{backgroundColor: "var(--surface-sidebar)", borderColor: "var(--border-subtle)", width: 220}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="px-4 pt-4 pb-3"
|
|
||||||
style={{borderBottom: "0.5px solid var(--border-subtle)"}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-[30px] h-[30px] rounded-xl flex items-center justify-center font-semibold text-[12px]"
|
|
||||||
style={{ backgroundColor: "var(--accent)" }}>
|
|
||||||
{username ? username[0].toUpperCase() : "U"}
|
|
||||||
</div>
|
|
||||||
<span className="text-[14px] font-medium truncate" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{onCollapse && (
|
|
||||||
<button
|
|
||||||
onClick={onCollapse}
|
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md transition-colors cursor-pointer"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
title="Collapse sidebar"
|
|
||||||
>
|
|
||||||
<PanelLeftClose className="w-[14px] h-[14px]" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-2">
|
|
||||||
{ME_NAV_ITEMS.slice(0, 4).map((item) => {
|
|
||||||
const active = isActive(item.path, item.end);
|
|
||||||
const count = getCount(item.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className="flex items-center gap-2 py-[7px] px-3 mx-2 rounded-lg transition-colors relative"
|
|
||||||
style={{
|
|
||||||
color: active ? "var(--text-primary)" : "var(--text-secondary)",
|
|
||||||
backgroundColor: active ? "var(--surface-ground)" : "transparent",
|
|
||||||
fontWeight: active ? 500 : 400,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{active && (
|
|
||||||
<div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm" style={{ backgroundColor: "var(--accent)" }} />
|
|
||||||
)}
|
|
||||||
<item.icon className="w-[15px] h-[15px] shrink-0" style={{ color: active ? "var(--text-primary)" : "var(--text-muted)" }}/>
|
|
||||||
<span className="flex-1 truncate">{item.name}</span>
|
|
||||||
{count !== null && count !== undefined && (
|
|
||||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", color: "var(--text-muted)", border: "0.5px solid var(--border-subtle)" }}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="px-4 pt-3 pb-1 mt-1"
|
|
||||||
style={{ fontSize: 11, fontWeight: 500, color: "var(--text-muted)", letterSpacing: "0.04em" }}>
|
|
||||||
Social
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ME_NAV_ITEMS.slice(4).map((item) => {
|
|
||||||
const active = isActive(item.path, item.end);
|
|
||||||
const count = getCount(item.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className="flex items-center gap-2 py-[7px] px-3 mx-2 rounded-lg transition-colors relative"
|
|
||||||
style={{
|
|
||||||
color: active ? "var(--text-primary)" : "var(--text-secondary)",
|
|
||||||
backgroundColor: active ? "var(--surface-ground)" : "transparent",
|
|
||||||
fontWeight: active ? 500 : 400,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{active && (
|
|
||||||
<div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm" style={{ backgroundColor: "var(--accent)" }} />
|
|
||||||
)}
|
|
||||||
<item.icon className="w-[15px] h-[15px] shrink-0" style={{ color: active ? "var(--text-primary)" : "var(--text-muted)" }}/>
|
|
||||||
<span className="flex-1 truncate">{item.name}</span>
|
|
||||||
{count !== null && count !== undefined && (
|
|
||||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", color: "var(--text-muted)", border: "0.5px solid var(--border-subtle)" }}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import {
|
|
||||||
notificationList,
|
|
||||||
notificationMarkRead,
|
|
||||||
notificationMarkAllRead,
|
|
||||||
} from "@/client/api"
|
|
||||||
import type { NotificationResponse } from "@/client/model"
|
|
||||||
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from "@/components/ui/empty"
|
|
||||||
|
|
||||||
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
|
||||||
mention: "Mention",
|
|
||||||
invitation: "Invitation",
|
|
||||||
role_change: "Role Change",
|
|
||||||
room_created: "Room Created",
|
|
||||||
room_deleted: "Room Deleted",
|
|
||||||
system_announcement: "Announcement",
|
|
||||||
project_invitation: "Project Invitation",
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationItem({
|
|
||||||
notification,
|
|
||||||
}: {
|
|
||||||
notification: NotificationResponse
|
|
||||||
}) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const markReadMutation = useMutation({
|
|
||||||
mutationFn: () => notificationMarkRead(notification.id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationList"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-2xl border text-left transition-all hover:-translate-y-0.5"
|
|
||||||
style={{
|
|
||||||
backgroundColor: notification.is_read
|
|
||||||
? "var(--surface-secondary)"
|
|
||||||
: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-subtle)",
|
|
||||||
opacity: notification.is_read ? 0.85 : 1,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!notification.is_read) markReadMutation.mutate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className="flex size-8 shrink-0 items-center justify-center rounded-xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: notification.is_read
|
|
||||||
? "var(--surface-ground)"
|
|
||||||
: "var(--accent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notification.is_read ? (
|
|
||||||
<MailOpen
|
|
||||||
className="size-4"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Mail className="size-4 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-[12px] font-medium"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{NOTIFICATION_TYPE_LABELS[notification.notification_type] ||
|
|
||||||
notification.notification_type}
|
|
||||||
</span>
|
|
||||||
{!notification.is_read && (
|
|
||||||
<span
|
|
||||||
className="size-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="ml-auto text-[11px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{new Date(notification.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="mt-1 truncate text-[13px] font-medium"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{notification.title}
|
|
||||||
</p>
|
|
||||||
{notification.content && (
|
|
||||||
<p
|
|
||||||
className="mt-1 line-clamp-2 text-[12px]"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{notification.content}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{(notification.room || notification.project) && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{notification.project && (
|
|
||||||
<Badge variant="outline">{`Project: ${notification.project}`}</Badge>
|
|
||||||
)}
|
|
||||||
{notification.room && (
|
|
||||||
<Badge variant="outline">{`Room: ${notification.room}`}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationList() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [onlyUnread, setOnlyUnread] = useState(false)
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
|
||||||
queryKey: ["notificationList", onlyUnread],
|
|
||||||
queryFn: () =>
|
|
||||||
notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const markAllReadMutation = useMutation({
|
|
||||||
mutationFn: () => notificationMarkAllRead(),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationList"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<Loader2
|
|
||||||
className="size-6 animate-spin"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<Bell />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>通知加载失败</EmptyTitle>
|
|
||||||
<EmptyDescription>请刷新页面或检查网络连接。</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications = data?.data?.data?.notifications ?? []
|
|
||||||
const unreadCount = data?.data?.data?.unread_count ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Bell
|
|
||||||
className="size-4"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-[13px]"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-full px-3 py-1.5 text-[12px] transition-colors"
|
|
||||||
style={{
|
|
||||||
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
|
|
||||||
backgroundColor: onlyUnread
|
|
||||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
onClick={() => setOnlyUnread(!onlyUnread)}
|
|
||||||
>
|
|
||||||
Unread only
|
|
||||||
</button>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1.5 rounded-full text-[12px]"
|
|
||||||
onClick={() => markAllReadMutation.mutate()}
|
|
||||||
disabled={markAllReadMutation.isPending}
|
|
||||||
>
|
|
||||||
<CheckCheck className="size-3.5" />
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)] py-14">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<Bell />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>暂无通知</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
{onlyUnread ? "没有未读通知" : "你已经查看完了"}
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{notifications.map((n) => (
|
|
||||||
<NotificationItem key={n.id} notification={n} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Link as LinkIcon, Building2, Calendar } from "lucide-react"
|
|
||||||
import type { UserInfoExternal } from "@/client/model"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import {
|
|
||||||
useUserFollowerCountQuery,
|
|
||||||
useUserStarsQuery,
|
|
||||||
useFollowMutation,
|
|
||||||
useUnfollowMutation,
|
|
||||||
} from "@/hooks/useUserQuery"
|
|
||||||
import { t } from "@/i18n/T"
|
|
||||||
|
|
||||||
interface ProfileHeaderProps {
|
|
||||||
user: UserInfoExternal | null | undefined
|
|
||||||
isMe: boolean
|
|
||||||
isLoading?: boolean
|
|
||||||
starsCount?: number
|
|
||||||
followerCount?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatBlock({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
value: string | number
|
|
||||||
label: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-[var(--surface-ground)] px-3 py-2 text-center ring-1 ring-[var(--border-subtle)]">
|
|
||||||
<div
|
|
||||||
className="text-[15px] font-semibold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-[10px] tracking-[0.22em] uppercase"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileHeader({
|
|
||||||
user,
|
|
||||||
isMe,
|
|
||||||
isLoading,
|
|
||||||
starsCount: starsCountProp,
|
|
||||||
followerCount: followerCountProp,
|
|
||||||
}: ProfileHeaderProps) {
|
|
||||||
const { data: starsData } = useUserStarsQuery(user?.username || "")
|
|
||||||
const { data: followerCountApi } = useUserFollowerCountQuery(
|
|
||||||
user?.username || ""
|
|
||||||
)
|
|
||||||
const followMutation = useFollowMutation()
|
|
||||||
const unfollowMutation = useUnfollowMutation()
|
|
||||||
|
|
||||||
const starsCount = starsCountProp ?? starsData?.total ?? 0
|
|
||||||
const followerCount = followerCountProp ?? followerCountApi ?? 0
|
|
||||||
|
|
||||||
const handleFollow = async () => {
|
|
||||||
if (!user) return
|
|
||||||
try {
|
|
||||||
await followMutation.mutateAsync(user.username)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Follow failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnfollow = async () => {
|
|
||||||
if (!user) return
|
|
||||||
try {
|
|
||||||
await unfollowMutation.mutateAsync(user.username)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Unfollow failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading || !user) {
|
|
||||||
return (
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-6 md:flex-row md:items-start">
|
|
||||||
<Skeleton className="size-20 rounded-2xl" />
|
|
||||||
<div className="min-w-0 flex-1 space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid gap-3 sm:grid-cols-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-16 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardHeader className="gap-4">
|
|
||||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
|
||||||
<Avatar className="size-20 rounded-2xl ring-1 ring-[var(--border-subtle)]">
|
|
||||||
<AvatarImage
|
|
||||||
src={user.avatar_url || undefined}
|
|
||||||
alt={user.username}
|
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
className="rounded-2xl text-2xl font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.username[0].toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<CardTitle className="text-[22px] leading-tight">
|
|
||||||
{user.display_name || user.username}
|
|
||||||
</CardTitle>
|
|
||||||
{isMe && <Badge variant="secondary">Me</Badge>}
|
|
||||||
</div>
|
|
||||||
<CardDescription className="mt-1 text-[13px]">
|
|
||||||
@{user.username}
|
|
||||||
</CardDescription>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mt-4 flex flex-wrap gap-2 text-[12px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)]">
|
|
||||||
<Calendar className="size-3.5" />
|
|
||||||
<span>
|
|
||||||
{t("me.profile.joined")}{" "}
|
|
||||||
{user.created_at ? format(new Date(user.created_at), "MMM yyyy") : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{user.organization && (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)]">
|
|
||||||
<Building2 className="size-3.5" />
|
|
||||||
<span>{user.organization}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{user.website_url && (
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
user.website_url.startsWith("http")
|
|
||||||
? user.website_url
|
|
||||||
: `https://${user.website_url}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)] transition-colors hover:text-[var(--text-primary)]"
|
|
||||||
style={{ color: "var(--text-info)" }}
|
|
||||||
>
|
|
||||||
<LinkIcon className="size-3.5" />
|
|
||||||
<span className="truncate">
|
|
||||||
{user.website_url.replace(/^https?:\/\//, "")}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMe && (
|
|
||||||
<Button
|
|
||||||
variant={user.is_subscribe ? "outline" : "default"}
|
|
||||||
className={user.is_subscribe ? "" : "text-[var(--accent-fg)]"}
|
|
||||||
style={
|
|
||||||
user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }
|
|
||||||
}
|
|
||||||
onClick={user.is_subscribe ? handleUnfollow : handleFollow}
|
|
||||||
disabled={followMutation.isPending || unfollowMutation.isPending}
|
|
||||||
>
|
|
||||||
{followMutation.isPending || unfollowMutation.isPending
|
|
||||||
? "..."
|
|
||||||
: user.is_subscribe
|
|
||||||
? t("me.profile.unfollow")
|
|
||||||
: t("me.profile.follow")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="pb-6">
|
|
||||||
<Separator />
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<StatBlock
|
|
||||||
value={user.total_projects}
|
|
||||||
label={t("me.profile.projects")}
|
|
||||||
/>
|
|
||||||
<StatBlock value={user.total_repos} label={t("me.profile.repos")} />
|
|
||||||
<StatBlock value={starsCount} label={t("me.profile.stars")} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (window.location.href = "/me/followers")}
|
|
||||||
className="rounded-xl text-left transition-transform hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<StatBlock
|
|
||||||
value={followerCount}
|
|
||||||
label={t("me.profile.followers")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { Users, Lock } from "lucide-react"
|
|
||||||
import type { UserProjectInfo } from "@/client/model"
|
|
||||||
import { Card } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from "@/components/ui/empty"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { t } from "@/i18n/T"
|
|
||||||
|
|
||||||
interface ProjectListProps {
|
|
||||||
projects: UserProjectInfo[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectList({ projects, isLoading }: ProjectListProps) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<Card key={i} size="sm">
|
|
||||||
<div className="space-y-3 px-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="size-8 rounded-lg" />
|
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
<Skeleton className="h-2 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
<Skeleton className="h-3 w-2/3" />
|
|
||||||
<div className="flex items-center gap-4 pt-1">
|
|
||||||
<Skeleton className="h-2.5 w-20" />
|
|
||||||
<Skeleton className="h-2.5 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return (
|
|
||||||
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<Users />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>{t("me.top_projects")}</EmptyTitle>
|
|
||||||
<EmptyDescription>暂无项目</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<Card
|
|
||||||
key={project.uid}
|
|
||||||
size="sm"
|
|
||||||
className="cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-[var(--accent)]/20"
|
|
||||||
onClick={() => navigate(`/${project.name}`)}
|
|
||||||
>
|
|
||||||
<div className="px-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.display_name[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3
|
|
||||||
className="truncate text-[13px] font-semibold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{project.display_name}
|
|
||||||
</h3>
|
|
||||||
{!project.is_public && (
|
|
||||||
<Lock
|
|
||||||
className="size-3.5"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-[11px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
@{project.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
className="mt-3 line-clamp-2 min-h-[2.5rem] text-[12px]"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{project.description || "暂无描述"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mt-3 flex items-center gap-4 text-[11px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Users className="size-3" />
|
|
||||||
<span>{project.member_count} members</span>
|
|
||||||
</div>
|
|
||||||
<span>{new Date(project.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { GitBranch, Lock } from "lucide-react"
|
|
||||||
import type { UserRepoInfo } from "@/client/model"
|
|
||||||
import { Card } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from "@/components/ui/empty"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { t } from "@/i18n/T"
|
|
||||||
|
|
||||||
interface RepoListProps {
|
|
||||||
repos: UserRepoInfo[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepoList({ repos, isLoading }: RepoListProps) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<Card key={i} size="sm">
|
|
||||||
<div className="space-y-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="size-4 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
<Skeleton className="h-3 w-2/3" />
|
|
||||||
<div className="flex items-center gap-4 pt-1">
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repos.length === 0) {
|
|
||||||
return (
|
|
||||||
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<GitBranch />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>{t("me.recent_repos")}</EmptyTitle>
|
|
||||||
<EmptyDescription>暂无仓库</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<Card
|
|
||||||
key={repo.uid}
|
|
||||||
size="sm"
|
|
||||||
className="cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-[var(--accent)]/20"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/${repo.project_name}/repo/${repo.repo_name}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GitBranch
|
|
||||||
className="size-4"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
/>
|
|
||||||
<h3
|
|
||||||
className="truncate text-[13px] font-semibold"
|
|
||||||
style={{ color: "var(--text-info)" }}
|
|
||||||
>
|
|
||||||
{repo.repo_name}
|
|
||||||
</h3>
|
|
||||||
{repo.is_private && (
|
|
||||||
<Lock
|
|
||||||
className="size-3.5"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="mt-3 line-clamp-2 min-h-[2.5rem] text-[12px]"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{repo.description || "暂无描述"}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="mt-3 flex items-center gap-4 text-[11px]"
|
|
||||||
style={{ color: "var(--text-tertiary)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="size-2 rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="font-medium"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{repo.default_branch}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
Updated {new Date(repo.updated_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { UserPlus, UserCheck } from "lucide-react";
|
|
||||||
import type { UserCard } from "@/client/model";
|
|
||||||
|
|
||||||
interface UserCardListProps {
|
|
||||||
users: UserCard[];
|
|
||||||
onToggleFollow?: (username: string, isFollowing: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserCardList({ users, onToggleFollow }: UserCardListProps) {
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<p>No users found</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{users.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.user_uid}
|
|
||||||
className="flex items-center gap-3 p-4 rounded-lg border transition-colors"
|
|
||||||
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = "var(--accent)"}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.borderColor = "var(--border-default)"}
|
|
||||||
>
|
|
||||||
<Avatar className="w-12 h-12 rounded-lg">
|
|
||||||
<AvatarImage src={user.avatar_url || undefined} alt={user.username} />
|
|
||||||
<AvatarFallback className="rounded-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
|
|
||||||
{user.username[0].toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-semibold truncate" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{user.display_name || user.username}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>
|
|
||||||
@{user.username}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={user.is_following_me ? "outline" : "default"}
|
|
||||||
className="h-8"
|
|
||||||
style={!user.is_following_me ? { backgroundColor: "var(--accent)", color: "white" } : {}}
|
|
||||||
onClick={() => onToggleFollow?.(user.username, user.is_following_me)}
|
|
||||||
>
|
|
||||||
{user.is_following_me ? <UserCheck className="w-4 h-4 mr-1" /> : <UserPlus className="w-4 h-4 mr-1" />}
|
|
||||||
{user.is_following_me ? "Following" : "Follow"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
|
||||||
size?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingSpinner({ size = 24, className = "" }: LoadingSpinnerProps) {
|
|
||||||
return (
|
|
||||||
<Loader2
|
|
||||||
className={`animate-spin text-muted-foreground ${className}`}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user