refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { TrendingUp, Users, Monitor, Tag } from "lucide-react"
|
||||
import ReactEChartsCore from "echarts-for-react/lib/core"
|
||||
import * as echarts from "echarts/core"
|
||||
import { LineChart, BarChart } from "echarts/charts"
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
|
||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
interface AnalyticsData {
|
||||
benchmarkTimeline: Array<{ day: string; count: number }>
|
||||
userTimeline: Array<{ day: string; count: number }>
|
||||
deviceDistribution: Array<{ hardwareSlug: string; hardwareName: string; count: number }>
|
||||
genrePopularity: Array<{ genre: string; count: number }>
|
||||
commentTimeline: Array<{ day: string; count: number }>
|
||||
}
|
||||
|
||||
export function AnalyticsClient() {
|
||||
const [data, setData] = useState<AnalyticsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/analytics/overview")
|
||||
.then((res) => res.json())
|
||||
.then((d) => { setData(d); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 rounded-lg bg-zinc-800" />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-center py-12 text-text/50">Failed to load analytics</div>
|
||||
}
|
||||
|
||||
const benchmarkOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.benchmarkTimeline.map((d) => d.day), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "line", data: data.benchmarkTimeline.map((d) => d.count), smooth: true, itemStyle: { color: "#eb3779" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const userOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.userTimeline.map((d) => d.day), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Registrations", type: "line", data: data.userTimeline.map((d) => d.count), smooth: true, itemStyle: { color: "#a78bfa" }, areaStyle: { color: "rgba(167,139,250,0.1)" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const deviceOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.deviceDistribution.map((d) => d.hardwareName), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "bar", data: data.deviceDistribution.map((d) => d.count), itemStyle: { color: "#fb793c" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const genreOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.genrePopularity.map((d) => d.genre), axisLabel: { fontSize: 10, rotate: 30 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "bar", data: data.genrePopularity.map((d) => d.count), itemStyle: { color: "#22c55e" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 60 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Analytics</h2>
|
||||
<p className="text-sm text-zinc-400">90-day trends and distributions</p>
|
||||
</div>
|
||||
|
||||
{/* Benchmark Submissions Over Time */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Benchmark Submissions (90d)</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={benchmarkOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
|
||||
{/* User Registrations Over Time */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="h-4 w-4 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold">User Registrations (90d)</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={userOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
|
||||
{/* Device Distribution + Genre Popularity (side by side) */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Monitor className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold">Device Distribution</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={deviceOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="h-4 w-4 text-green-400" />
|
||||
<h3 className="text-sm font-semibold">Genre Popularity</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={genreOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// Future: ECharts integration for trends over time
|
||||
// For now, the overview stats are sufficient
|
||||
export function DashboardCharts() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Gamepad2,
|
||||
BarChart3,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
MessageSquarePlus,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
ShieldX,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardStats {
|
||||
overview: {
|
||||
totalGames: number;
|
||||
totalBenchmarks: number;
|
||||
totalUsers: number;
|
||||
pendingReports: number;
|
||||
pendingSuggestions: number;
|
||||
};
|
||||
recent: {
|
||||
benchmarksLast30Days: number;
|
||||
gamesLast30Days: number;
|
||||
};
|
||||
topContributors: Array<{
|
||||
userId: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
syncHealth: Record<string, number>;
|
||||
gamesBySource: Record<string, number>;
|
||||
playabilityDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dashboard/stats")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-64 rounded-lg bg-zinc-800" />;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "Total Games",
|
||||
value: stats.overview.totalGames,
|
||||
icon: Gamepad2,
|
||||
color: "text-blue-400",
|
||||
},
|
||||
{
|
||||
label: "Total Benchmarks",
|
||||
value: stats.overview.totalBenchmarks,
|
||||
icon: BarChart3,
|
||||
color: "text-green-400",
|
||||
},
|
||||
{
|
||||
label: "Total Users",
|
||||
value: stats.overview.totalUsers,
|
||||
icon: Users,
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{
|
||||
label: "Pending Reports",
|
||||
value: stats.overview.pendingReports,
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-400",
|
||||
highlight: stats.overview.pendingReports > 0,
|
||||
},
|
||||
{
|
||||
label: "Pending Suggestions",
|
||||
value: stats.overview.pendingSuggestions,
|
||||
icon: MessageSquarePlus,
|
||||
color: "text-amber-400",
|
||||
highlight: stats.overview.pendingSuggestions > 0,
|
||||
},
|
||||
{
|
||||
label: "Benchmarks (30d)",
|
||||
value: stats.recent.benchmarksLast30Days,
|
||||
icon: TrendingUp,
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "New Games (30d)",
|
||||
value: stats.recent.gamesLast30Days,
|
||||
icon: Gamepad2,
|
||||
color: "text-cyan-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-7">
|
||||
{statCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-lg border bg-zinc-900 p-4",
|
||||
card.highlight ? "border-amber-500/30" : "border-zinc-800"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4", card.color)} />
|
||||
<span className="text-xs text-zinc-500">{card.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{card.value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid: Top Contributors + Playability Distribution */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Top Contributors */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Top Contributors</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.topContributors.slice(0, 5).map((contributor, i) => (
|
||||
<div
|
||||
key={contributor.userId}
|
||||
className="flex items-center justify-between rounded-md bg-zinc-800/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-500">#{i + 1}</span>
|
||||
<span className="text-sm">{contributor.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-zinc-400">
|
||||
{contributor.count} entries
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playability Distribution */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Playability Distribution</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.playabilityDistribution).map(([status, count]) => {
|
||||
const config: Record<string, { label: string; color: string }> = {
|
||||
great: { label: "Plays Great", color: "bg-emerald-500" },
|
||||
playable: { label: "Playable", color: "bg-blue-500" },
|
||||
needs_tweaks: { label: "Needs Tweaks", color: "bg-amber-500" },
|
||||
unplayable: { label: "Unplayable", color: "bg-red-500" },
|
||||
unknown: { label: "Unknown", color: "bg-zinc-500" },
|
||||
};
|
||||
const c = config[status] ?? config.unknown;
|
||||
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-3">
|
||||
<span className={cn("h-3 w-3 rounded-full", c.color)} />
|
||||
<span className="flex-1 text-sm">{c.label}</span>
|
||||
<span className="text-sm font-medium">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Health */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Steam Sync Health</h3>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(stats.syncHealth).map(([status, count]) => {
|
||||
const icons: Record<string, typeof Shield> = {
|
||||
synced: ShieldCheck,
|
||||
failed: ShieldX,
|
||||
pending: Shield,
|
||||
};
|
||||
const colors: Record<string, string> = {
|
||||
synced: "text-green-400",
|
||||
failed: "text-red-400",
|
||||
pending: "text-yellow-400",
|
||||
};
|
||||
const Icon = icons[status] ?? Shield;
|
||||
const color = colors[status] ?? "text-zinc-400";
|
||||
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
<Icon className={cn("h-5 w-5", color)} />
|
||||
<div>
|
||||
<p className="text-lg font-bold">{count}</p>
|
||||
<p className="text-xs capitalize text-zinc-500">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
import {
|
||||
UsersIcon,
|
||||
CpuIcon,
|
||||
Gamepad2Icon,
|
||||
HardDriveIcon,
|
||||
MessageSquareIcon,
|
||||
MessageSquarePlusIcon,
|
||||
FlagIcon,
|
||||
BarChart3Icon,
|
||||
LayoutDashboardIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type NavItem =
|
||||
| { type: "section"; label: string }
|
||||
| { type: "divider" }
|
||||
| { type: "link"; href: string; label: string; icon: React.ElementType; adminOnly?: boolean }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ type: "section", label: "Overview" },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ type: "section", label: "Management" },
|
||||
{ type: "link", href: "/manage/users", label: "Users", icon: UsersIcon, adminOnly: true },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/hardware",
|
||||
label: "Hardware",
|
||||
icon: CpuIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/storage",
|
||||
label: "Storage",
|
||||
icon: HardDriveIcon,
|
||||
adminOnly: true,
|
||||
},
|
||||
{ type: "link", href: "/manage/games", label: "Games", icon: Gamepad2Icon },
|
||||
{ type: "divider" },
|
||||
{ type: "section", label: "Moderation" },
|
||||
{ type: "link", href: "/manage/reports", label: "Reports", icon: FlagIcon },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/suggestions",
|
||||
label: "Suggestions",
|
||||
icon: MessageSquarePlusIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/benchmarks",
|
||||
label: "Benchmarks",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/comments",
|
||||
label: "Comments",
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export function ManageSidebar() {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = useSession()
|
||||
const role = session?.user?.role ?? "user"
|
||||
const isAdmin = role === "admin"
|
||||
|
||||
return (
|
||||
<nav className='md:w-56 shrink-0'>
|
||||
<div className='hidden md:flex items-center gap-2 px-3 py-2 mb-2 text-sm font-semibold text-text/70'>
|
||||
<LayoutDashboardIcon className='h-4 w-4' />
|
||||
Manage Panel
|
||||
</div>
|
||||
|
||||
<div className='flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border md:pr-3'>
|
||||
{navItems.map((item, index) => {
|
||||
// Hide admin-only items for non-admins
|
||||
if (item.type === "link" && item.adminOnly && !isAdmin) return null
|
||||
|
||||
if (item.type === "section") {
|
||||
return (
|
||||
<div
|
||||
key={`section-${item.label}`}
|
||||
className='hidden md:block px-3 py-1 text-[10px] font-semibold uppercase tracking-widest text-text/30'
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type === "divider") {
|
||||
return [
|
||||
<div
|
||||
key={`div-m-${index}`}
|
||||
className='w-px bg-border shrink-0 self-stretch md:hidden'
|
||||
/>,
|
||||
<div
|
||||
key={`div-d-${index}`}
|
||||
className='hidden md:block h-px bg-border'
|
||||
/>,
|
||||
]
|
||||
}
|
||||
|
||||
const isActive = pathname === item.href
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2 px-4 md:px-3 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<item.icon className='h-4 w-4 shrink-0' />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user