mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-11 15:18:31 +09:00
Initial open source release
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Star,
|
||||
Folder,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Link2,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
emoji: string | null;
|
||||
member_count?: number;
|
||||
};
|
||||
|
||||
type SidebarData = {
|
||||
ok: boolean;
|
||||
total: number;
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
type DaemonStatus = {
|
||||
ok: boolean;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [data, setData] = useState<SidebarData | null>(null);
|
||||
const [daemon, setDaemon] = useState<DaemonStatus | null>(null);
|
||||
const [unsorted, setUnsorted] = useState(0);
|
||||
const [favorites, setFavorites] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/sessions');
|
||||
const j = await r.json();
|
||||
setData(j);
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch('/api/stats?range=week');
|
||||
const j = await r.json();
|
||||
if (j.ok && j.sidebar_counts) {
|
||||
setUnsorted(j.sidebar_counts.unsorted);
|
||||
setFavorites(j.sidebar_counts.favorites);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch('/api/daemon');
|
||||
const j = await r.json();
|
||||
setDaemon(j);
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[rgba(10,16,12,0.86)] backdrop-blur">
|
||||
<div className="border-b border-[var(--border-soft)] px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="min-w-0">
|
||||
<div className="report-kicker">WeChat Radar</div>
|
||||
<div className="mt-1 text-[15px] font-semibold tracking-wide text-[var(--text)]">
|
||||
微信雷达
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
className="rounded-md p-1 text-[var(--text-3)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||
aria-label="设置"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-[var(--text-3)]">私有看板 · 高信号优先</div>
|
||||
</div>
|
||||
|
||||
<nav className="px-2 pb-2 pt-3">
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={<LayoutDashboard size={15} />}
|
||||
label="看板"
|
||||
badge="Brief"
|
||||
active={pathname === '/'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/signals"
|
||||
icon={<Activity size={15} />}
|
||||
label="信号流"
|
||||
badge="Live"
|
||||
active={pathname === '/signals'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/topics"
|
||||
icon={<Sparkles size={15} />}
|
||||
label="话题雷达"
|
||||
badge="Cross"
|
||||
active={pathname === '/topics'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/links"
|
||||
icon={<Link2 size={15} />}
|
||||
label="链接情报"
|
||||
badge="Link"
|
||||
active={pathname === '/links'}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="px-4 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||
Groups
|
||||
</div>
|
||||
<nav className="px-2">
|
||||
<NavItem href="/groups" icon={<Inbox size={15} />} label="所有群" count={data?.total} />
|
||||
<NavItem href="/groups?filter=favorites" icon={<Star size={15} />} label="收藏" count={favorites} />
|
||||
<NavItem
|
||||
href="/groups?filter=unsorted"
|
||||
icon={<Folder size={15} />}
|
||||
label="未分组"
|
||||
count={unsorted}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||
Collections
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<CategoryItem key={c.id} category={c} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-soft)] px-4 py-2 text-[11px] text-[var(--text-3)]">
|
||||
{daemon?.running ? (
|
||||
<span>
|
||||
<span className="inline-block size-2 rounded-full bg-[var(--accent)] mr-1.5 align-middle" />
|
||||
wx-daemon 运行中{daemon.pid ? ` (PID ${daemon.pid})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="inline-block size-2 rounded-full bg-[var(--danger)] mr-1.5 align-middle" />
|
||||
wx-daemon 未运行
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
badge,
|
||||
count,
|
||||
active,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
badge?: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`group relative flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] text-[var(--text)]'
|
||||
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-[var(--accent)]" />}
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{count !== undefined && (
|
||||
<span className="text-[11px] text-[var(--text-3)] tabular-nums">{count}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryItem({ category }: { category: Category }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/groups?filter=group&group_id=${category.id}`}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] text-[var(--text-2)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{category.emoji ? `${category.emoji} ` : ''}
|
||||
{category.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--text-3)] tabular-nums">
|
||||
{category.member_count ?? 0}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user