mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-25 18:14:35 -05:00
Add dashboard and hack insights
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -15,10 +15,12 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.74.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
@@ -690,6 +692,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz",
|
||||
@@ -2052,6 +2060,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -4771,6 +4791,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-chartjs-2": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.74.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
|
||||
233
src/app/dashboard/actions.ts
Normal file
233
src/app/dashboard/actions.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
"use server";
|
||||
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import { createClient, createServiceClient } from "@/utils/supabase/server";
|
||||
|
||||
interface SeriesDataset {
|
||||
slug: string;
|
||||
counts: number[]; // aligned with labels
|
||||
}
|
||||
|
||||
export interface DownloadsSeriesAll {
|
||||
labels: string[]; // YYYY-MM-DD (UTC), length = days, ending yesterday
|
||||
datasets: SeriesDataset[];
|
||||
lastComputedUtc: string; // ISO of generation time
|
||||
}
|
||||
|
||||
export interface HackInsights {
|
||||
versionCounts: { version: string; downloads: number }[];
|
||||
totalUniqueDevices: number;
|
||||
latestUniqueDevices: number;
|
||||
adoptionRate: number; // 0..1
|
||||
isNewToday: boolean;
|
||||
}
|
||||
|
||||
function getUtcBounds(days: number) {
|
||||
const now = new Date();
|
||||
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const endISO = startOfTodayUtc.toISOString(); // exclude today
|
||||
const startUtc = new Date(startOfTodayUtc);
|
||||
startUtc.setUTCDate(startUtc.getUTCDate() - days);
|
||||
const startISO = startUtc.toISOString();
|
||||
const nextMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
const ttl = Math.max(1, Math.ceil((+nextMidnight - +now) / 1000));
|
||||
const dayStamp = startOfTodayUtc.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
return { startISO, endISO, ttl, dayStamp, startOfTodayUtc };
|
||||
}
|
||||
|
||||
function buildUtcDateLabels(days: number, endExclusiveUtc: Date): string[] {
|
||||
const labels: string[] = [];
|
||||
for (let i = days; i >= 1; i--) {
|
||||
const d = new Date(endExclusiveUtc);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
labels.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export const getDownloadsSeriesAll = async ({ days = 30 }: { days?: number }): Promise<DownloadsSeriesAll> => {
|
||||
const { startISO, endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(days);
|
||||
|
||||
// Resolve user and owned slugs OUTSIDE cache (cookies not allowed in cache)
|
||||
const supa = await createClient();
|
||||
const { data: userResp } = await supa.auth.getUser();
|
||||
const user = userResp.user;
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
const { data: hacks } = await supa
|
||||
.from("hacks")
|
||||
.select("slug")
|
||||
.eq("created_by", user.id);
|
||||
const slugs = (hacks ?? []).map((h) => h.slug);
|
||||
|
||||
const runner = cache(
|
||||
async () => {
|
||||
if (slugs.length === 0) {
|
||||
return {
|
||||
labels: buildUtcDateLabels(days, startOfTodayUtc),
|
||||
datasets: [],
|
||||
lastComputedUtc: new Date().toISOString(),
|
||||
} satisfies DownloadsSeriesAll;
|
||||
}
|
||||
|
||||
// Fetch patches for all owned hacks (service client only)
|
||||
const svc = await createServiceClient();
|
||||
const { error: patchError, data: patchRows } = await svc
|
||||
.from("patches")
|
||||
.select("id,parent_hack")
|
||||
.in("parent_hack", slugs);
|
||||
if (patchError) throw patchError;
|
||||
|
||||
const patchIdToSlug = new Map<number, string>();
|
||||
const patchIds: number[] = [];
|
||||
(patchRows ?? []).forEach((p) => {
|
||||
if (typeof p.id === "number" && p.parent_hack) {
|
||||
patchIdToSlug.set(p.id, p.parent_hack);
|
||||
patchIds.push(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
const labels = buildUtcDateLabels(days, startOfTodayUtc);
|
||||
const dateIndex = new Map<string, number>();
|
||||
labels.forEach((d, i) => dateIndex.set(d, i));
|
||||
|
||||
const countsBySlug: Record<string, number[]> = {};
|
||||
slugs.forEach((s) => (countsBySlug[s] = new Array(labels.length).fill(0)));
|
||||
|
||||
if (patchIds.length > 0) {
|
||||
const { error: dlError, data: dlRows } = await svc
|
||||
.from("patch_downloads")
|
||||
.select("created_at,patch")
|
||||
.in("patch", patchIds)
|
||||
.gte("created_at", startISO)
|
||||
.lt("created_at", endISO);
|
||||
if (dlError) throw dlError;
|
||||
|
||||
(dlRows ?? []).forEach((row: any) => {
|
||||
const pid = row.patch as number | null;
|
||||
if (!pid) return;
|
||||
const slug = patchIdToSlug.get(pid);
|
||||
if (!slug) return;
|
||||
const day = new Date(row.created_at).toISOString().slice(0, 10);
|
||||
const idx = dateIndex.get(day);
|
||||
if (idx == null) return;
|
||||
countsBySlug[slug][idx] += 1;
|
||||
});
|
||||
}
|
||||
|
||||
const datasets: SeriesDataset[] = slugs.map((slug) => ({ slug, counts: countsBySlug[slug] || new Array(labels.length).fill(0) }));
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
lastComputedUtc: new Date().toISOString(),
|
||||
} satisfies DownloadsSeriesAll;
|
||||
},
|
||||
[
|
||||
`downloads-series-all:${user.id}:${dayStamp}`,
|
||||
],
|
||||
{ revalidate: ttl }
|
||||
);
|
||||
|
||||
return runner();
|
||||
};
|
||||
|
||||
export const getHackInsights = async ({ slug }: { slug: string }): Promise<HackInsights> => {
|
||||
const { endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(30);
|
||||
|
||||
// Authorization OUTSIDE cache (cookies not allowed in cache)
|
||||
const supa = await createClient();
|
||||
const { data: userResp } = await supa.auth.getUser();
|
||||
const user = userResp.user;
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
const { data: hack } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,created_by,current_patch,created_at")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) throw new Error("Not found");
|
||||
let isOwner = hack.created_by === user.id;
|
||||
if (!isOwner) {
|
||||
const { data: admin } = await supa.rpc("is_admin");
|
||||
if (!admin) throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
const currentPatchId = (hack.current_patch as number | null) ?? null;
|
||||
const hackCreatedAt = hack.created_at ? new Date(hack.created_at) : null;
|
||||
|
||||
const runner = cache(
|
||||
async () => {
|
||||
const svc = await createServiceClient();
|
||||
const { error: patchError, data: patches } = await svc
|
||||
.from("patches")
|
||||
.select("id,version,created_at")
|
||||
.eq("parent_hack", slug);
|
||||
if (patchError) throw patchError;
|
||||
const patchIds = (patches ?? []).map((p) => p.id).filter((v): v is number => typeof v === "number");
|
||||
|
||||
// Determine latest patch id
|
||||
let latestPatchId: number | null = currentPatchId;
|
||||
if (latestPatchId == null && (patches ?? []).length > 0) {
|
||||
const latest = [...(patches ?? [])].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
latestPatchId = latest?.id ?? null;
|
||||
}
|
||||
|
||||
// New today message if hack or latest patch is created today (UTC)
|
||||
const latestPatch = (patches ?? []).find((p) => p.id === latestPatchId) || null;
|
||||
const isNewToday = Boolean(
|
||||
(hackCreatedAt && hackCreatedAt >= startOfTodayUtc) || (latestPatch && new Date(latestPatch.created_at) >= startOfTodayUtc)
|
||||
);
|
||||
|
||||
if (patchIds.length === 0) {
|
||||
return {
|
||||
versionCounts: [],
|
||||
totalUniqueDevices: 0,
|
||||
latestUniqueDevices: 0,
|
||||
adoptionRate: 0,
|
||||
isNewToday,
|
||||
} satisfies HackInsights;
|
||||
}
|
||||
|
||||
const { error: dlError, data: dlRows } = await svc
|
||||
.from("patch_downloads")
|
||||
.select("patch,device_id")
|
||||
.in("patch", patchIds)
|
||||
.lt("created_at", endISO);
|
||||
if (dlError) throw dlError;
|
||||
|
||||
const byPatchCount = new Map<number, number>();
|
||||
const allDevices = new Set<string>();
|
||||
const latestDevices = new Set<string>();
|
||||
|
||||
(dlRows ?? []).forEach((r: any) => {
|
||||
const pid = r.patch as number | null;
|
||||
const dev = r.device_id as string | null;
|
||||
if (pid == null) return;
|
||||
byPatchCount.set(pid, (byPatchCount.get(pid) ?? 0) + 1);
|
||||
if (dev) allDevices.add(dev);
|
||||
if (latestPatchId != null && pid === latestPatchId && dev) latestDevices.add(dev);
|
||||
});
|
||||
|
||||
const versionCounts = (patches ?? [])
|
||||
.map((p) => ({ version: p.version, downloads: byPatchCount.get(p.id) ?? 0 }))
|
||||
.sort((a, b) => b.downloads - a.downloads);
|
||||
|
||||
const totalUniqueDevices = allDevices.size;
|
||||
const latestUniqueDevices = latestDevices.size;
|
||||
const adoptionRate = totalUniqueDevices > 0 ? latestUniqueDevices / totalUniqueDevices : 0;
|
||||
|
||||
return {
|
||||
versionCounts,
|
||||
totalUniqueDevices,
|
||||
latestUniqueDevices,
|
||||
adoptionRate,
|
||||
isNewToday,
|
||||
} satisfies HackInsights;
|
||||
},
|
||||
[
|
||||
`hack-insights:${user.id}:${slug}:${dayStamp}`,
|
||||
],
|
||||
{ revalidate: ttl }
|
||||
);
|
||||
|
||||
return runner();
|
||||
};
|
||||
44
src/app/dashboard/page.tsx
Normal file
44
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import DashboardClient from "@/components/Dashboard/DashboardClient";
|
||||
import { getDownloadsSeriesAll } from "./actions";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supa = await createClient();
|
||||
const { data: userResp } = await supa.auth.getUser();
|
||||
const user = userResp.user;
|
||||
if (!user) redirect("/login");
|
||||
|
||||
|
||||
const { data: profile } = await supa
|
||||
.from("profiles")
|
||||
.select("username,full_name")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile || profile.username == null) {
|
||||
redirect("/account");
|
||||
}
|
||||
|
||||
const { username, full_name } = profile;
|
||||
|
||||
const { data: hacks } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,title,approved,updated_at,downloads,current_patch,version,created_at")
|
||||
.eq("created_by", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
const seriesAll = await getDownloadsSeriesAll({ days: 30 });
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8">
|
||||
<DashboardClient
|
||||
hacks={hacks ?? []}
|
||||
initialSeriesAll={seriesAll}
|
||||
displayName={full_name || `@${username}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
src/app/hack/[slug]/stats/page.tsx
Normal file
41
src/app/hack/[slug]/stats/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import HackStatsClient from "@/components/Hack/Stats/HackStatsClient";
|
||||
import { getDownloadsSeriesAll, getHackInsights } from "@/app/dashboard/actions";
|
||||
|
||||
export default async function HackStatsPage({ params: { slug } }: { params: { slug: string } }) {
|
||||
const supa = await createClient();
|
||||
const { data: userResp } = await supa.auth.getUser();
|
||||
const user = userResp.user;
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: hack } = await supa
|
||||
.from("hacks")
|
||||
.select("slug,created_by,title")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) notFound();
|
||||
|
||||
let isOwner = hack.created_by === user.id;
|
||||
if (!isOwner) {
|
||||
const { data: admin } = await supa.rpc("is_admin");
|
||||
if (!admin) notFound();
|
||||
}
|
||||
|
||||
const allSeries = await getDownloadsSeriesAll({ days: 30 });
|
||||
const series = {
|
||||
labels: allSeries.labels,
|
||||
datasets: allSeries.datasets.filter((d) => d.slug === slug),
|
||||
lastComputedUtc: allSeries.lastComputedUtc,
|
||||
};
|
||||
|
||||
const insights = await getHackInsights({ slug });
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8">
|
||||
<HackStatsClient slug={slug} title={hack.title} initialSeries={series} initialInsights={insights} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function login(state: AuthActionState, payload: FormData) {
|
||||
return {
|
||||
error: null,
|
||||
user: authData.user,
|
||||
redirectTo: isValidInternalPath ? redirectTo : '/account'
|
||||
redirectTo: isValidInternalPath ? redirectTo : '/dashboard'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function LoginForm() {
|
||||
useEffect(() => {
|
||||
if (state && state.error === null && !navigatedRef.current) {
|
||||
setUser(state.user);
|
||||
const to = state.redirectTo || (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/account');
|
||||
const to = state.redirectTo || (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/dashboard');
|
||||
navigatedRef.current = true;
|
||||
router.replace(to);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function LoginForm() {
|
||||
useEffect(() => {
|
||||
if (!user || navigatedRef.current) return;
|
||||
const isValidInternalPath = !!redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//');
|
||||
const to = isValidInternalPath ? (redirectTo as string) : '/account';
|
||||
const to = isValidInternalPath ? (redirectTo as string) : '/dashboard';
|
||||
navigatedRef.current = true;
|
||||
router.replace(to);
|
||||
}, [user, redirectTo, router]);
|
||||
|
||||
167
src/components/Dashboard/DashboardClient.tsx
Normal file
167
src/components/Dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { DownloadsSeriesAll } from "@/app/dashboard/actions";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import DownloadsChart from "@/components/Dashboard/DownloadsChart";
|
||||
import HackList from "@/components/Dashboard/HackList";
|
||||
|
||||
type HackRow = {
|
||||
slug: string;
|
||||
title: string;
|
||||
approved: boolean;
|
||||
updated_at: string | null;
|
||||
downloads: number;
|
||||
current_patch: number | null;
|
||||
version: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function DashboardClient({
|
||||
hacks,
|
||||
initialSeriesAll,
|
||||
displayName,
|
||||
}: {
|
||||
hacks: HackRow[];
|
||||
initialSeriesAll: DownloadsSeriesAll;
|
||||
displayName: string;
|
||||
}) {
|
||||
const [selectedSlugs, setSelectedSlugs] = React.useState<string[]>(() => hacks.map((h) => h.slug));
|
||||
|
||||
const totalDownloads = React.useMemo(() => hacks.reduce((acc, h) => acc + (h.downloads || 0), 0), [hacks]);
|
||||
const pendingCount = hacks.filter((h) => !h.approved).length;
|
||||
const localCutover = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(utcMidnight);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardProvider initialSeriesAll={initialSeriesAll}>
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-start gap-3 lg:gap-4">
|
||||
<div className="flex flex-col grow-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Creator Dashboard</h1>
|
||||
<p className="mt-1 text-[18px] text-foreground/90">Welcome back, {displayName}!</p>
|
||||
<p className="mt-4 text-[15px] text-foreground/60">
|
||||
Analytics update daily at 00:00 UTC. Today's data will be available after {localCutover}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col ml-auto my-4 w-full md:flex-row md:w-auto md:mb-0 lg:my-0 gap-2">
|
||||
<Link
|
||||
href="/account"
|
||||
className="inline-flex h-12 px-4 items-center justify-center w-full md:w-auto md:h-10 rounded-md text-sm font-medium ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] hover:cursor-pointer"
|
||||
>
|
||||
Account Settings
|
||||
</Link>
|
||||
<form action="/auth/signout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-12 px-8 items-center justify-center w-full md:w-auto md:h-10 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 text-sm font-medium text-red-600/90 dark:text-red-400/80 transition-colors hover:bg-red-600/5 dark:hover:bg-red-400/10 hover:cursor-pointer"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="mt-6 grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Your hacks" value={hacks.length} />
|
||||
<StatCard label="Pending approval" value={pendingCount} />
|
||||
<StatCard label="Total downloads" value={totalDownloads} />
|
||||
<StatCard label="Last 30 days (UTC)" value={initialSeriesAll.datasets.reduce((acc, d) => acc + d.counts.reduce((a, b) => a + b, 0), 0)} />
|
||||
</div>
|
||||
|
||||
{/* Downloads over time */}
|
||||
<div className="mt-10">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold">Downloads over time (last 30 days, UTC)</h2>
|
||||
<SlugMultiSelect
|
||||
hacks={hacks}
|
||||
values={selectedSlugs}
|
||||
onChange={setSelectedSlugs}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DownloadsChart selectedSlugs={selectedSlugs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hacks list */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold">Your hacks</h2>
|
||||
<HackList hacks={hacks} />
|
||||
</div>
|
||||
|
||||
{/* Per-hack insights removed; deeper stats are on each hack's /stats page */}
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-4">
|
||||
<div className="text-[13px] text-foreground/70">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlugMultiSelect({
|
||||
hacks,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
hacks: HackRow[];
|
||||
values: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 w-full -mx-1 px-1">
|
||||
{hacks.map((h) => {
|
||||
const selected = values.includes(h.slug);
|
||||
return (
|
||||
<button
|
||||
key={h.slug}
|
||||
type="button"
|
||||
onClick={() => onChange(selected ? values.filter((s) => s !== h.slug) : [...values, h.slug])}
|
||||
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors hover:cursor-pointer ${
|
||||
selected
|
||||
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{h.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hacks.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(hacks.map((h) => h.slug))}
|
||||
className="shrink-0 rounded-full ml-auto px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
)}
|
||||
{values.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer ${values.length === 0 ? "ml-auto" : ""}`}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
138
src/components/Dashboard/DownloadsChart.tsx
Normal file
138
src/components/Dashboard/DownloadsChart.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ChartOptions,
|
||||
} from "chart.js";
|
||||
import { useDashboard } from "@/contexts/DashboardContext";
|
||||
|
||||
ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, Filler);
|
||||
|
||||
export default function DownloadsChart({ selectedSlugs }: { selectedSlugs: string[] }) {
|
||||
const { seriesAllHacks } = useDashboard();
|
||||
|
||||
const palette = React.useMemo(
|
||||
() => [
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#a855f7",
|
||||
"#06b6d4",
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const datasets = React.useMemo(() => {
|
||||
const filtered = seriesAllHacks.datasets.filter((d) => selectedSlugs.includes(d.slug));
|
||||
return filtered.map((d, idx) => {
|
||||
const color = palette[idx % palette.length];
|
||||
return {
|
||||
label: d.slug,
|
||||
data: d.counts,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "33",
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 2,
|
||||
} as const;
|
||||
});
|
||||
}, [seriesAllHacks.datasets, selectedSlugs, palette]);
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
const years = new Set(seriesAllHacks.labels.map((ds) => Number(ds.slice(0, 4))));
|
||||
// Format YYYY-MM-DD to "06 Oct" or "Oct 06" depending on locale (always treat as UTC)
|
||||
const labels = seriesAllHacks.labels.map((dateStr, i) => {
|
||||
// Directly parse components from YYYY-MM-DD string to avoid timezone conversion
|
||||
const [yearStr, monthStr, dayStr] = dateStr.split("-");
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr); // 1-based
|
||||
const day = Number(dayStr);
|
||||
|
||||
// Show the year if it's the first/second of January and years are not all the same
|
||||
// Two days because maxTicksLimit is half of the label count.
|
||||
let shouldShowYear = false;
|
||||
if (i === 0 || (day === 1 && month === 1) || (day === 2 && month === 1)) {
|
||||
if (years.size > 1 && month === 1 && day === 1) {
|
||||
shouldShowYear = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a GMT Date so locale options format, but still UTC.
|
||||
// month-1 because Date.UTC months are zero-based.
|
||||
const d = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
let baseFormat = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
}).format(d);
|
||||
|
||||
if (shouldShowYear) {
|
||||
baseFormat += ` ${year}`;
|
||||
}
|
||||
return baseFormat;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}, [seriesAllHacks.labels, datasets]);
|
||||
|
||||
const options: ChartOptions<"line"> = React.useMemo(
|
||||
() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index" as const, intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: (items: any[]) => {
|
||||
try {
|
||||
const sum = items.reduce((acc, it) => acc + (Number(it.parsed.y) || 0), 0);
|
||||
return `Total: ${sum}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { autoSkip: true, maxTicksLimit: 15 },
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { stepSize: 1 },
|
||||
grid: { color: "rgba(0,0,0,0.1)" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-[40vh] sm:h-72 w-full max-w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
{datasets.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/70">Select at least one hack to display.</div>
|
||||
) : (
|
||||
<Line data={data} options={options} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
106
src/components/Dashboard/HackInsights.tsx
Normal file
106
src/components/Dashboard/HackInsights.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useDashboard } from "@/contexts/DashboardContext";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||
|
||||
interface HackRow {
|
||||
slug: string;
|
||||
title: string;
|
||||
approved: boolean;
|
||||
updated_at: string | null;
|
||||
downloads: number;
|
||||
current_patch: number | null;
|
||||
version: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function HackInsights({ hack }: { hack: HackRow }) {
|
||||
const { getInsights } = useDashboard();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<null | Awaited<ReturnType<typeof getInsights>>>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || data) return;
|
||||
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
getInsights(hack.slug)
|
||||
.then((res) => {
|
||||
if (mounted) setData(res);
|
||||
})
|
||||
.finally(() => mounted && setLoading(false));
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [open, data, getInsights, hack.slug]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{hack.title}</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/60">/{hack.slug}</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground/70">{open ? "Hide" : "Show"} insights</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 pb-4">
|
||||
{loading || !data ? (
|
||||
<div className="py-6 text-sm text-foreground/70">Loading insights…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="md:col-span-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="text-sm font-medium mb-2">Downloads per version</div>
|
||||
{data.versionCounts.length === 0 ? (
|
||||
<div className="text-sm text-foreground/70">No downloads yet. Upload a patch to see data.</div>
|
||||
) : (
|
||||
<Bar
|
||||
data={{
|
||||
labels: data.versionCounts.map((v) => v.version),
|
||||
datasets: [
|
||||
{
|
||||
label: "Downloads",
|
||||
data: data.versionCounts.map((v) => v.downloads),
|
||||
backgroundColor: "#3b82f6",
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } },
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="text-sm font-medium mb-2">Latest-version adoption</div>
|
||||
{data.isNewToday ? (
|
||||
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{Math.round(data.adoptionRate * 100)}%</div>
|
||||
<div className="mt-1 text-xs text-foreground/70">{data.latestUniqueDevices} of {data.totalUniqueDevices} unique devices on latest</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
226
src/components/Dashboard/HackList.tsx
Normal file
226
src/components/Dashboard/HackList.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { FiExternalLink, FiEdit2, FiUpload, FiShare2, FiBarChart2, FiMoreVertical, FiCheck } from "react-icons/fi";
|
||||
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
|
||||
import ActionSheet from "@/components/Primitives/ActionSheet";
|
||||
|
||||
type HackRow = {
|
||||
slug: string;
|
||||
title: string;
|
||||
approved: boolean;
|
||||
updated_at: string | null;
|
||||
downloads: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export default function HackList({ hacks }: { hacks: HackRow[] }) {
|
||||
const [activeSlug, setActiveSlug] = React.useState<string | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
|
||||
if (hacks.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-6 text-sm text-foreground/80">
|
||||
You haven't uploaded any hacks yet. <Link className="underline" href="/submit">Submit a hack</Link> to get started.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border border-[var(--border)]">
|
||||
{/* Header row (desktop only) */}
|
||||
<div className="hidden lg:grid grid-cols-12 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
|
||||
<div className="col-span-4">Title</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-2">Version</div>
|
||||
<div className="col-span-2">Downloads</div>
|
||||
<div className="col-span-2 text-right">Actions</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{hacks.map((h) => (
|
||||
<div key={h.slug} className="px-4 py-3 text-sm">
|
||||
{/* Desktop row */}
|
||||
<div className="hidden lg:grid grid-cols-12 items-center">
|
||||
<Link href={`/hack/${h.slug}`} target="_blank" className="group flex items-center gap-4 col-span-4 min-w-0 hover:text-foreground">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="truncate font-medium group-hover:underline">{h.title}</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{h.slug}</div>
|
||||
</div>
|
||||
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground" />
|
||||
</Link>
|
||||
<div className="col-span-2">
|
||||
{h.approved ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-400 ring-1 ring-amber-600/30">Pending</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2">{h.version}</div>
|
||||
<div className="col-span-2">{h.downloads}</div>
|
||||
<div className="col-span-2 hidden md:flex items-center justify-end gap-1.5">
|
||||
<IconTooltipButton href={`/hack/${h.slug}/stats`} target="_blank" label="Stats">
|
||||
<FiBarChart2 className="h-4 w-4" />
|
||||
</IconTooltipButton>
|
||||
<IconTooltipButton href={`/hack/${h.slug}/edit`} label="Edit">
|
||||
<FiEdit2 className="h-4 w-4" />
|
||||
</IconTooltipButton>
|
||||
<IconTooltipButton href={`/hack/${h.slug}/edit/patch`} label="Upload patch">
|
||||
<FiUpload className="h-4 w-4" />
|
||||
</IconTooltipButton>
|
||||
<ShareIconButton slug={h.slug} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile card */}
|
||||
<div className="lg:hidden flex justify-between items-center">
|
||||
<div className="flex flex-col items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium break-words">{h.title}</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
{h.approved ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-amber-400 ring-1 ring-amber-600/30">Pending</span>
|
||||
)}
|
||||
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.version}</span>
|
||||
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.downloads} downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<IconTooltipButton onClick={() => { setActiveSlug(h.slug); setSheetOpen(true); }} label="More" ariaLabel="More">
|
||||
<FiMoreVertical className="h-4 w-4" />
|
||||
</IconTooltipButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ActionSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
title={activeSlug ? `Actions for ${activeSlug}` : undefined}
|
||||
actions={buildActions(activeSlug)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareIconButton({ slug }: { slug: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${origin}/hack/${slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconTooltipButton onClick={handleClick} label={copied ? "Copied!" : "Share link"} ariaLabel="Share link">
|
||||
{copied ? <FiCheck className="h-4 w-4 text-emerald-400/80" /> : <FiShare2 className="h-4 w-4" />}
|
||||
</IconTooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
function buildActions(slug: string | null) {
|
||||
if (!slug) return [];
|
||||
return [
|
||||
{ key: "view", label: "View", href: `/hack/${slug}`, icon: <FiExternalLink className="h-4 w-4" /> },
|
||||
{ key: "stats", label: "Stats", href: `/hack/${slug}/stats`, icon: <FiBarChart2 className="h-4 w-4" /> },
|
||||
{ key: "edit", label: "Edit", href: `/hack/${slug}/edit`, icon: <FiEdit2 className="h-4 w-4" /> },
|
||||
{ key: "upload", label: "Upload patch", href: `/hack/${slug}/edit/patch`, icon: <FiUpload className="h-4 w-4" /> },
|
||||
{ key: "share", label: "Share link", onClick: () => copyShare(slug), icon: <FiShare2 className="h-4 w-4" /> },
|
||||
];
|
||||
}
|
||||
|
||||
async function copyShare(slug: string) {
|
||||
try {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${origin}/hack/${slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type IconTooltipButtonProps = {
|
||||
href: string;
|
||||
target?: string;
|
||||
onClick?: never;
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
} | {
|
||||
href?: never;
|
||||
target?: never;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
function IconTooltipButton({ href, target, onClick, label, ariaLabel, children }: IconTooltipButtonProps) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift()],
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const ref = refs.reference.current;
|
||||
const float = refs.floating.current;
|
||||
if (!ref || !float) return;
|
||||
return autoUpdate(ref, float, update);
|
||||
}, [refs.reference, refs.floating, update]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative inline-flex"
|
||||
ref={refs.setReference as any}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
ref={refs.setFloating as any}
|
||||
style={floatingStyles as React.CSSProperties}
|
||||
className="hidden lg:block z-50 whitespace-nowrap rounded-md bg-black/80 px-2 py-1 text-[11px] text-white shadow-md dark:bg-white/90 dark:text-black"
|
||||
role="tooltip"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
139
src/components/Hack/Stats/HackStatsCharts.tsx
Normal file
139
src/components/Hack/Stats/HackStatsCharts.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Line, Bar } from "react-chartjs-2";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
BarElement,
|
||||
ChartData,
|
||||
ChartDataset,
|
||||
ChartOptions,
|
||||
} from "chart.js";
|
||||
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
|
||||
|
||||
ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, Filler, BarElement);
|
||||
type LineData = ChartData<"line", number[], string>;
|
||||
type BarData = ChartData<"bar", (number | [number, number] | null)[], unknown>;
|
||||
|
||||
interface HackStatsChartsProps {
|
||||
series: DownloadsSeriesAll;
|
||||
insights: HackInsights;
|
||||
activeTab?: "overview" | "versions";
|
||||
}
|
||||
|
||||
export default function HackStatsCharts({ series, insights, activeTab }: HackStatsChartsProps) {
|
||||
const lineData: LineData = React.useMemo(() => ({
|
||||
labels: series.labels,
|
||||
datasets: series.datasets.map((d, i) => ({
|
||||
label: d.slug,
|
||||
data: d.counts,
|
||||
borderColor: "#22c55e",
|
||||
backgroundColor: "#22c55e33",
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 2,
|
||||
}) satisfies ChartDataset<"line">),
|
||||
}) satisfies LineData, [series]);
|
||||
|
||||
const lineOptions: ChartOptions<"line"> = React.useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index" as const, intersect: false },
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { grid: { display: false } }, y: { beginAtZero: true } },
|
||||
}) satisfies ChartOptions<"line">, []);
|
||||
|
||||
const barData: BarData = React.useMemo(() => ({
|
||||
labels: insights.versionCounts.map((v) => v.version),
|
||||
datasets: [{ label: "Downloads", data: insights.versionCounts.map((v) => v.downloads), backgroundColor: "#3b82f6" }],
|
||||
}) satisfies BarData, [insights]);
|
||||
|
||||
// Mobile: show one chart by tab; Desktop: show both
|
||||
if (activeTab) {
|
||||
return (
|
||||
<div className="space-y-6 md:space-y-0 md:grid md:grid-cols-3 md:gap-6">
|
||||
{activeTab === "overview" ? (
|
||||
<div className="md:col-span-3 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
|
||||
<div className="h-[60vh] sm:h-72 max-w-full overflow-hidden">
|
||||
{series.datasets.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
|
||||
) : (
|
||||
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
|
||||
{insights.isNewToday ? (
|
||||
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
|
||||
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
|
||||
<div className="h-[50vh] sm:h-64">
|
||||
{insights.versionCounts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
|
||||
) : (
|
||||
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
|
||||
<div className="h-72 max-w-full overflow-hidden">
|
||||
{series.datasets.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
|
||||
) : (
|
||||
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
|
||||
{insights.isNewToday ? (
|
||||
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
|
||||
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 lg:col-span-3">
|
||||
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
|
||||
<div className="h-64">
|
||||
{insights.versionCounts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
|
||||
) : (
|
||||
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
67
src/components/Hack/Stats/HackStatsClient.tsx
Normal file
67
src/components/Hack/Stats/HackStatsClient.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
|
||||
import HackStatsCharts from "@/components/Hack/Stats/HackStatsCharts";
|
||||
|
||||
export default function HackStatsClient({
|
||||
slug,
|
||||
title,
|
||||
initialSeries,
|
||||
initialInsights,
|
||||
}: {
|
||||
slug: string;
|
||||
title: string;
|
||||
initialSeries: DownloadsSeriesAll;
|
||||
initialInsights: HackInsights;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = React.useState<"overview" | "versions">("overview");
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Stats: {title}</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">Analytics update daily at 00:00 UTC. Today's data appears tomorrow.</p>
|
||||
</div>
|
||||
<Link href={`/hack/${slug}`} className="inline-flex h-10 items-center rounded-md px-4 text-sm ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]">Back to hack</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile segmented control */}
|
||||
{isMobile && (
|
||||
<div className="mt-4" role="tablist" aria-label="Stats tabs">
|
||||
<div className="inline-flex rounded-md ring-1 ring-[var(--border)] p-0.5">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === "overview"}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
className={`px-3 py-1.5 text-sm rounded ${activeTab === "overview" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === "versions"}
|
||||
onClick={() => setActiveTab("versions")}
|
||||
className={`px-3 py-1.5 text-sm rounded ${activeTab === "versions" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<HackStatsCharts series={initialSeries} insights={initialInsights} activeTab={isMobile ? activeTab : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,11 +120,11 @@ export default function Header() {
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/account"
|
||||
data-active={pathname === "/account" || undefined}
|
||||
href="/dashboard"
|
||||
data-active={pathname === "/dashboard" || undefined}
|
||||
className="ml-1 relative group inline-flex items-center justify-center rounded-full ring-1 ring-[var(--border)] p-[2px] data-active:ring-2 data-active:ring-[var(--ring)]"
|
||||
aria-label="Open account"
|
||||
title="Account"
|
||||
aria-label="Open dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<Avatar uid={userId} url={avatarUrl} size={36} />
|
||||
<div className="absolute inset-0 rounded-full bg-transparent group-hover:bg-black/30 transition-colors m-[2px]" />
|
||||
@@ -183,15 +183,15 @@ export default function Header() {
|
||||
/>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/account"
|
||||
href="/dashboard"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
data-active={pathname === "/account" || undefined}
|
||||
data-active={pathname === "/dashboard" || undefined}
|
||||
className="mt-1 inline-flex items-center gap-3 rounded-md px-3 py-3 ring-1 ring-[var(--border)]"
|
||||
aria-label="Open account"
|
||||
title="Account"
|
||||
aria-label="Open dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<Avatar uid={userId} url={avatarUrl} size={28} />
|
||||
<span className="text-[15px]">Account</span>
|
||||
<span className="text-[15px]">Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
103
src/components/Primitives/ActionSheet.tsx
Normal file
103
src/components/Primitives/ActionSheet.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { Dialog, Transition, TransitionChild, DialogPanel } from "@headlessui/react";
|
||||
|
||||
export interface ActionItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface ActionSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
actions: ActionItem[];
|
||||
}
|
||||
|
||||
export default function ActionSheet({ open, onClose, title, actions }: ActionSheetProps) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transform transition ease-out duration-200"
|
||||
enterFrom="translate-y-full"
|
||||
enterTo="translate-y-0"
|
||||
leave="transform transition ease-in duration-150"
|
||||
leaveFrom="translate-y-0"
|
||||
leaveTo="translate-y-full"
|
||||
>
|
||||
<DialogPanel className="w-full">
|
||||
<div className="mx-auto w-full max-w-screen-sm rounded-t-2xl bg-background ring-1 ring-[var(--border)]">
|
||||
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-foreground/20" />
|
||||
{title && (
|
||||
<div className="px-4 pb-2 pt-1 text-center text-sm font-medium text-foreground/90">{title}</div>
|
||||
)}
|
||||
<div className="px-2 pb-2">
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border)]">
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{actions.map((a) => (
|
||||
<li key={a.key}>
|
||||
{a.href ? (
|
||||
<a
|
||||
href={a.href}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 px-4 py-3 text-[15px] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<span className="text-foreground/80">{a.icon}</span>
|
||||
<span className="text-foreground">{a.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
try { a.onClick?.(); } finally { onClose(); }
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left text-[15px] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<span className="text-foreground/80">{a.icon}</span>
|
||||
<span className="text-foreground">{a.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-2 mb-4 flex w-full items-center justify-center rounded-xl bg-[var(--surface-2)] px-4 py-3 text-[15px] ring-1 ring-[var(--border)] hover:bg-[var(--surface-3)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
src/contexts/DashboardContext.tsx
Normal file
47
src/contexts/DashboardContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
|
||||
import { getHackInsights } from "@/app/dashboard/actions";
|
||||
|
||||
type DashboardContextValue = {
|
||||
seriesAllHacks: DownloadsSeriesAll;
|
||||
insightsBySlug: Map<string, HackInsights>;
|
||||
getInsights: (slug: string) => Promise<HackInsights>;
|
||||
};
|
||||
|
||||
export const DashboardContext = React.createContext<DashboardContextValue | null>(null);
|
||||
|
||||
export function DashboardProvider({
|
||||
children,
|
||||
initialSeriesAll,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSeriesAll: DownloadsSeriesAll;
|
||||
}) {
|
||||
const insightsRef = React.useRef<Map<string, HackInsights>>(new Map());
|
||||
|
||||
const getInsights = React.useCallback(async (slug: string) => {
|
||||
const cached = insightsRef.current.get(slug);
|
||||
if (cached) return cached;
|
||||
const result = await getHackInsights({ slug });
|
||||
insightsRef.current.set(slug, result);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<DashboardContextValue>(() => ({
|
||||
seriesAllHacks: initialSeriesAll,
|
||||
insightsBySlug: insightsRef.current,
|
||||
getInsights,
|
||||
}), [initialSeriesAll, getInsights]);
|
||||
|
||||
return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const ctx = React.useContext(DashboardContext);
|
||||
if (!ctx) throw new Error("useDashboard must be used within DashboardProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user