diff --git a/src/app/api/patches/[id]/applied/route.ts b/src/app/api/patches/[id]/applied/route.ts new file mode 100644 index 0000000..dd754e2 --- /dev/null +++ b/src/app/api/patches/[id]/applied/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { createClient } from "@/utils/supabase/server"; + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { deviceId } = await req.json().catch(() => ({ deviceId: undefined })); + if (!deviceId || typeof deviceId !== "string") { + return new Response("Missing deviceId", { status: 400 }); + } + + const supabase = await createClient(); + const patchId = Number(id); + if (!Number.isFinite(patchId)) { + return new Response("Bad id", { status: 400 }); + } + + // Try insert; if unique constraint violation, treat as success (already counted) + const { error } = await supabase + .from("patch_downloads") + .insert({ patch: patchId, device_id: deviceId }); + + if (error) { + // PostgREST unique violation codes commonly surface as 409 or PGRST specific; best-effort accept duplicates + if ((error as any).code === "23505" || /duplicate|unique/i.test(error.message)) { + return new Response(null, { status: 200 }); + } + return new Response(error.message || "Failed", { status: 500 }); + } + + return new Response(null, { status: 201 }); +} diff --git a/src/app/api/patches/[id]/download/route.ts b/src/app/api/patches/[id]/download/route.ts index 0c46504..7ca5a4b 100644 --- a/src/app/api/patches/[id]/download/route.ts +++ b/src/app/api/patches/[id]/download/route.ts @@ -15,11 +15,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return new Response("Not found", { status: 404 }); } - // Log download attempt (fire-and-forget) - try { - await supabase.from("patch_downloads").insert({ patch: patch.id }); - } catch {} - const client = getMinioClient(); const bucket = patch.bucket || PATCHES_BUCKET; const url = await client.presignedGetObject(bucket, patch.filename, 60 * 5); diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 98316f1..f064022 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -2,15 +2,15 @@ import { baseRoms } from "@/data/baseRoms"; import { notFound } from "next/navigation"; import Gallery from "@/components/Hack/Gallery"; import HackActions from "@/components/Hack/HackActions"; -import { formatCompactNumber } from "@/utils/format"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import Image from "next/image"; -import { FaDiscord, FaDownload, FaTwitter } from "react-icons/fa6"; +import { FaDiscord, FaTwitter } from "react-icons/fa6"; import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; import { createClient } from "@/utils/supabase/server"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; import HackOptionsMenu from "@/components/Hack/HackOptionsMenu"; +import DownloadsBadge from "@/components/Hack/DownloadsBadge"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -63,6 +63,7 @@ export default async function HackDetail({ params }: HackDetailProps) { // Resolve a short-lived signed patch URL (if current_patch exists) let signedPatchUrl = ""; let patchVersion = ""; + let patchId: number | null = null; let lastUpdated: string | null = null; if (hack.current_patch != null) { const { data: patch } = await supabase @@ -75,6 +76,7 @@ export default async function HackDetail({ params }: HackDetailProps) { const bucket = patch.bucket || PATCHES_BUCKET; signedPatchUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5); patchVersion = patch.version || ""; + patchId = patch.id; lastUpdated = new Date(patch.created_at).toLocaleDateString(); } } @@ -88,6 +90,8 @@ export default async function HackDetail({ params }: HackDetailProps) { baseRomId={baseRom?.id || ""} platform={baseRom?.platform} patchUrl={signedPatchUrl} + patchId={patchId ?? undefined} + hackSlug={hack.slug} />
@@ -110,10 +114,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
-
- - {formatCompactNumber(hack.downloads)} -
+
diff --git a/src/components/Hack/DownloadsBadge.tsx b/src/components/Hack/DownloadsBadge.tsx new file mode 100644 index 0000000..9a999a6 --- /dev/null +++ b/src/components/Hack/DownloadsBadge.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { FaDownload } from "react-icons/fa6"; +import { formatCompactNumber } from "@/utils/format"; +import type { DownloadEvent } from "@/types/util"; + +export default function DownloadsBadge({ slug, initialCount }: { slug: string; initialCount: number }) { + const [count, setCount] = React.useState(initialCount); + const [anim, setAnim] = React.useState(false); + + React.useEffect(() => { + function onApplied(e: Event) { + const detail = (e as DownloadEvent).detail; + if (detail.slug !== slug) return; + setCount((c) => c + 1); + setAnim(true); + const t = setTimeout(() => setAnim(false), 600); + return () => clearTimeout(t); + } + window.addEventListener("hack:patch-applied", onApplied); + return () => window.removeEventListener("hack:patch-applied", onApplied); + }, [slug]); + + return ( +
+ + {formatCompactNumber(count)} +
+ ); +} + + diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 1f2b8f4..c88fec1 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -6,6 +6,7 @@ import { useBaseRoms } from "@/contexts/BaseRomContext"; import { baseRoms } from "@/data/baseRoms"; import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js"; import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js"; +import type { DownloadEventDetail } from "@/types/util"; interface HackActionsProps { title: string; @@ -14,6 +15,8 @@ interface HackActionsProps { baseRomId: string; platform?: "GBA" | "GBC" | "GB" | "NDS"; patchUrl: string; + patchId?: number; + hackSlug: string; } const HackActions: React.FC = ({ @@ -23,6 +26,8 @@ const HackActions: React.FC = ({ baseRomId, platform, patchUrl, + patchId, + hackSlug, }) => { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms(); const [file, setFile] = React.useState(null); @@ -160,6 +165,29 @@ const HackActions: React.FC = ({ patchedRom.save(); setStatus("done"); + + // Best-effort log applied event for counting and animate badge + try { + if (patchId != null) { + const key = "deviceId"; + let deviceId = localStorage.getItem(key); + if (!deviceId) { + deviceId = crypto.randomUUID(); + localStorage.setItem(key, deviceId); + } + await fetch(`/api/patches/${patchId}/applied`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId }), + }) + .then((res) => { + if (res.status === 201) { + window.dispatchEvent(new CustomEvent("hack:patch-applied", { detail: { slug: hackSlug } })); + } + }) + .catch(() => {}); + } + } catch {} } catch (e: any) { setError(e?.message || "Failed to patch ROM"); setStatus("idle"); diff --git a/src/types/db.ts b/src/types/db.ts index 5f9733e..8b7551d 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -174,16 +174,19 @@ export type Database = { patch_downloads: { Row: { created_at: string + device_id: string id: number patch: number | null } Insert: { created_at?: string + device_id: string id?: number patch?: number | null } Update: { created_at?: string + device_id?: string id?: number patch?: number | null } diff --git a/src/types/util.ts b/src/types/util.ts new file mode 100644 index 0000000..96b8af7 --- /dev/null +++ b/src/types/util.ts @@ -0,0 +1,5 @@ +export interface DownloadEventDetail { + slug: string; +} + +export type DownloadEvent = CustomEvent; diff --git a/supabase/migrations/20251024202142_downloads_counter.sql b/supabase/migrations/20251024202142_downloads_counter.sql new file mode 100644 index 0000000..2262ac0 --- /dev/null +++ b/supabase/migrations/20251024202142_downloads_counter.sql @@ -0,0 +1,103 @@ +-- Add device_id to patch_downloads and enforce uniqueness per patch/device +alter table if exists public.patch_downloads + add column if not exists device_id text; + +-- Backfill existing rows with a deterministic legacy value +update public.patch_downloads + set device_id = 'legacy-' || id + where device_id is null; + +-- Enforce NOT NULL after backfill +alter table public.patch_downloads + alter column device_id set not null; + +-- Unique index to dedupe per device per patch +create unique index if not exists patch_downloads_patch_device_id_key + on public.patch_downloads (patch, device_id); + +-- Ensure RLS enabled and allow anonymous inserts +alter table public.patch_downloads enable row level security; + +do $$ +begin + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = 'patch_downloads' + and policyname = 'Allow insert for everyone' + ) then + create policy "Allow insert for everyone" on public.patch_downloads + for insert + to public + with check (true); + end if; +end $$; + +-- Trigger functions to keep hacks.downloads in sync +create or replace function public.patch_downloads_inc_hack_downloads() +returns trigger +language plpgsql +as $$ +declare + hack_slug text; +begin + select p.parent_hack into hack_slug + from public.patches p + where p.id = NEW.patch; + + if hack_slug is not null then + update public.hacks h + set downloads = h.downloads + 1 + where h.slug = hack_slug; + end if; + + return NEW; +end; +$$; + +create or replace function public.patch_downloads_dec_hack_downloads() +returns trigger +language plpgsql +as $$ +declare + hack_slug text; +begin + select p.parent_hack into hack_slug + from public.patches p + where p.id = OLD.patch; + + if hack_slug is not null then + update public.hacks h + set downloads = greatest(h.downloads - 1, 0) + where h.slug = hack_slug; + end if; + + return OLD; +end; +$$; + +drop trigger if exists patch_downloads_ai on public.patch_downloads; +create trigger patch_downloads_ai +after insert on public.patch_downloads +for each row +execute function public.patch_downloads_inc_hack_downloads(); + +drop trigger if exists patch_downloads_ad on public.patch_downloads; +create trigger patch_downloads_ad +after delete on public.patch_downloads +for each row +execute function public.patch_downloads_dec_hack_downloads(); + +-- Backfill hacks.downloads to exact counts +update public.hacks h + set downloads = coalesce(sub.cnt, 0) +from ( + select h2.slug as slug, count(d.id) as cnt + from public.hacks h2 + left join public.patches p on p.parent_hack = h2.slug + left join public.patch_downloads d on d.patch = p.id + group by h2.slug +) sub +where h.slug = sub.slug; + + diff --git a/supabase/migrations/20251024231058_fix_hack_downloads_triggers.sql b/supabase/migrations/20251024231058_fix_hack_downloads_triggers.sql new file mode 100644 index 0000000..ffb9276 --- /dev/null +++ b/supabase/migrations/20251024231058_fix_hack_downloads_triggers.sql @@ -0,0 +1,56 @@ +-- Trigger functions to keep hacks.downloads in sync +create or replace function public.patch_downloads_inc_hack_downloads() +returns trigger +language plpgsql +security definer +as $$ +declare + hack_slug text; +begin + select p.parent_hack into hack_slug + from public.patches p + where p.id = NEW.patch; + + if hack_slug is not null then + update public.hacks h + set downloads = h.downloads + 1 + where h.slug = hack_slug; + end if; + + return NEW; +end; +$$; + +create or replace function public.patch_downloads_dec_hack_downloads() +returns trigger +language plpgsql +security definer +as $$ +declare + hack_slug text; +begin + select p.parent_hack into hack_slug + from public.patches p + where p.id = OLD.patch; + + if hack_slug is not null then + update public.hacks h + set downloads = greatest(h.downloads - 1, 0) + where h.slug = hack_slug; + end if; + + return OLD; +end; +$$; + +drop trigger if exists patch_downloads_ai on public.patch_downloads; +create trigger patch_downloads_ai +after insert on public.patch_downloads +for each row +execute function public.patch_downloads_inc_hack_downloads(); + +drop trigger if exists patch_downloads_ad on public.patch_downloads; +create trigger patch_downloads_ad +after delete on public.patch_downloads +for each row +execute function public.patch_downloads_dec_hack_downloads();