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:
zhenyi 2026-05-20 13:38:36 +08:00
parent 5827d561db
commit f6f69a063e
11 changed files with 0 additions and 1528 deletions

View File

@ -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>
)
})

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}