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();