diff --git a/package-lock.json b/package-lock.json index cf85840..84412ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4fd6103..6aa8d33 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts new file mode 100644 index 0000000..0870042 --- /dev/null +++ b/src/app/dashboard/actions.ts @@ -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 => { + 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(); + 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(); + labels.forEach((d, i) => dateIndex.set(d, i)); + + const countsBySlug: Record = {}; + 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 => { + 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(); + const allDevices = new Set(); + const latestDevices = new Set(); + + (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(); +}; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..c5489a9 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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 ( +
+ +
+ ); +} + + diff --git a/src/app/hack/[slug]/stats/page.tsx b/src/app/hack/[slug]/stats/page.tsx new file mode 100644 index 0000000..960de93 --- /dev/null +++ b/src/app/hack/[slug]/stats/page.tsx @@ -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 ( +
+ +
+ ); +} + + diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index efbb396..d595ec3 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -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' } } diff --git a/src/components/Auth/LoginForm.tsx b/src/components/Auth/LoginForm.tsx index 8d26d58..a86415f 100644 --- a/src/components/Auth/LoginForm.tsx +++ b/src/components/Auth/LoginForm.tsx @@ -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]); diff --git a/src/components/Dashboard/DashboardClient.tsx b/src/components/Dashboard/DashboardClient.tsx new file mode 100644 index 0000000..8567e49 --- /dev/null +++ b/src/components/Dashboard/DashboardClient.tsx @@ -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(() => 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 ( + +
+
+
+

Creator Dashboard

+

Welcome back, {displayName}!

+

+ Analytics update daily at 00:00 UTC. Today's data will be available after {localCutover}. +

+
+
+ + Account Settings + +
+ +
+
+
+ + {/* Quick stats */} +
+ + + + acc + d.counts.reduce((a, b) => a + b, 0), 0)} /> +
+ + {/* Downloads over time */} +
+
+

Downloads over time (last 30 days, UTC)

+ +
+
+ +
+
+ + {/* Hacks list */} +
+

Your hacks

+ +
+ + {/* Per-hack insights removed; deeper stats are on each hack's /stats page */} +
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SlugMultiSelect({ + hacks, + values, + onChange, +}: { + hacks: HackRow[]; + values: string[]; + onChange: (v: string[]) => void; +}) { + return ( +
+ {hacks.map((h) => { + const selected = values.includes(h.slug); + return ( + + ); + })} + {hacks.length > 1 && ( + + )} + {values.length > 0 && ( + + )} +
+ ); +} + + diff --git a/src/components/Dashboard/DownloadsChart.tsx b/src/components/Dashboard/DownloadsChart.tsx new file mode 100644 index 0000000..c625af2 --- /dev/null +++ b/src/components/Dashboard/DownloadsChart.tsx @@ -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 ( +
+ {datasets.length === 0 ? ( +
Select at least one hack to display.
+ ) : ( + + )} +
+ ); +} + + diff --git a/src/components/Dashboard/HackInsights.tsx b/src/components/Dashboard/HackInsights.tsx new file mode 100644 index 0000000..2c1fcd5 --- /dev/null +++ b/src/components/Dashboard/HackInsights.tsx @@ -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); + + 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 ( +
+ + {open && ( +
+ {loading || !data ? ( +
Loading insights…
+ ) : ( +
+
+
Downloads per version
+ {data.versionCounts.length === 0 ? ( +
No downloads yet. Upload a patch to see data.
+ ) : ( + 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} + /> + )} +
+
+
Latest-version adoption
+ {data.isNewToday ? ( +
New upload today—please check back tomorrow (UTC) for analytics.
+ ) : ( +
+
{Math.round(data.adoptionRate * 100)}%
+
{data.latestUniqueDevices} of {data.totalUniqueDevices} unique devices on latest
+
+ )} +
+
+ )} +
+ )} +
+ ); +} + + diff --git a/src/components/Dashboard/HackList.tsx b/src/components/Dashboard/HackList.tsx new file mode 100644 index 0000000..ba8b1ac --- /dev/null +++ b/src/components/Dashboard/HackList.tsx @@ -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(null); + const [sheetOpen, setSheetOpen] = React.useState(false); + + if (hacks.length === 0) { + return ( +
+ You haven't uploaded any hacks yet. Submit a hack to get started. +
+ ); + } + + return ( +
+ {/* Header row (desktop only) */} +
+
Title
+
Status
+
Version
+
Downloads
+
Actions
+
+
+ {hacks.map((h) => ( +
+ {/* Desktop row */} +
+ +
+
{h.title}
+
/{h.slug}
+
+ + +
+ {h.approved ? ( + Approved + ) : ( + Pending + )} +
+
{h.version}
+
{h.downloads}
+
+ + + + + + + + + + +
+
+ {/* Mobile card */} +
+
+
+
{h.title}
+
/{h.slug}
+
+
+ {h.approved ? ( + Approved + ) : ( + Pending + )} + {h.version} + {h.downloads} downloads +
+
+ { setActiveSlug(h.slug); setSheetOpen(true); }} label="More" ariaLabel="More"> + + +
+
+ ))} +
+ setSheetOpen(false)} + title={activeSlug ? `Actions for ${activeSlug}` : undefined} + actions={buildActions(activeSlug)} + /> +
+ ); +} + +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 ( + + {copied ? : } + + ); +} + +function buildActions(slug: string | null) { + if (!slug) return []; + return [ + { key: "view", label: "View", href: `/hack/${slug}`, icon: }, + { key: "stats", label: "Stats", href: `/hack/${slug}/stats`, icon: }, + { key: "edit", label: "Edit", href: `/hack/${slug}/edit`, icon: }, + { key: "upload", label: "Upload patch", href: `/hack/${slug}/edit/patch`, icon: }, + { key: "share", label: "Share link", onClick: () => copyShare(slug), icon: }, + ]; +} + +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 ( + + {href ? ( + + {children} + + ) : ( + + )} + + ); +} + +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 ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + > + {children} + {open && ( + + {label} + + )} + + ); +} + + diff --git a/src/components/Hack/Stats/HackStatsCharts.tsx b/src/components/Hack/Stats/HackStatsCharts.tsx new file mode 100644 index 0000000..70b45f0 --- /dev/null +++ b/src/components/Hack/Stats/HackStatsCharts.tsx @@ -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 ( +
+ {activeTab === "overview" ? ( +
+
Downloads over time (last 30 days, UTC)
+
+ {series.datasets.length === 0 ? ( +
No data yet.
+ ) : ( + + )} +
+
+ ) : ( + <> +
+
Latest-version adoption
+ {insights.isNewToday ? ( +
New upload today—please check back tomorrow (UTC) for analytics.
+ ) : ( +
+
{Math.round(insights.adoptionRate * 100)}%
+
{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest
+
+ )} +
+
+
Downloads per version (all-time)
+
+ {insights.versionCounts.length === 0 ? ( +
No downloads yet.
+ ) : ( + + )} +
+
+ + )} +
+ ); + } + + return ( +
+
+
Downloads over time (last 30 days, UTC)
+
+ {series.datasets.length === 0 ? ( +
No data yet.
+ ) : ( + + )} +
+
+
+
Latest-version adoption
+ {insights.isNewToday ? ( +
New upload today—please check back tomorrow (UTC) for analytics.
+ ) : ( +
+
{Math.round(insights.adoptionRate * 100)}%
+
{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest
+
+ )} +
+
+
Downloads per version (all-time)
+
+ {insights.versionCounts.length === 0 ? ( +
No downloads yet.
+ ) : ( + + )} +
+
+
+ ); +} + + diff --git a/src/components/Hack/Stats/HackStatsClient.tsx b/src/components/Hack/Stats/HackStatsClient.tsx new file mode 100644 index 0000000..bbd8d2c --- /dev/null +++ b/src/components/Hack/Stats/HackStatsClient.tsx @@ -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 ( +
+
+
+

Stats: {title}

+

Analytics update daily at 00:00 UTC. Today's data appears tomorrow.

+
+ Back to hack +
+ + {/* Mobile segmented control */} + {isMobile && ( +
+
+ + +
+
+ )} + +
+ +
+
+ ); +} + + diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8ff8991..6b4399a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -120,11 +120,11 @@ export default function Header() { {isAuthenticated && (
@@ -183,15 +183,15 @@ export default function Header() { /> {isAuthenticated && ( 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" > - Account + Dashboard )}
diff --git a/src/components/Primitives/ActionSheet.tsx b/src/components/Primitives/ActionSheet.tsx new file mode 100644 index 0000000..5909772 --- /dev/null +++ b/src/components/Primitives/ActionSheet.tsx @@ -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 ( + + + +
+ + +
+
+ + +
+
+ {title && ( +
{title}
+ )} +
+
+
    + {actions.map((a) => ( +
  • + {a.href ? ( + + {a.icon} + {a.label} + + ) : ( + + )} +
  • + ))} +
+
+ +
+
+ + +
+
+
+
+ ); +} + + + diff --git a/src/contexts/DashboardContext.tsx b/src/contexts/DashboardContext.tsx new file mode 100644 index 0000000..ddd58f5 --- /dev/null +++ b/src/contexts/DashboardContext.tsx @@ -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; + getInsights: (slug: string) => Promise; +}; + +export const DashboardContext = React.createContext(null); + +export function DashboardProvider({ + children, + initialSeriesAll, +}: { + children: React.ReactNode; + initialSeriesAll: DownloadsSeriesAll; +}) { + const insightsRef = React.useRef>(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(() => ({ + seriesAllHacks: initialSeriesAll, + insightsBySlug: insightsRef.current, + getInsights, + }), [initialSeriesAll, getInsights]); + + return {children}; +} + +export function useDashboard() { + const ctx = React.useContext(DashboardContext); + if (!ctx) throw new Error("useDashboard must be used within DashboardProvider"); + return ctx; +} + +