Add proper download counting functionality

This commit is contained in:
Jared Schoeny 2025-10-24 14:05:18 -10:00
parent 9287ddbace
commit 32d5af3c8e
9 changed files with 270 additions and 11 deletions

View File

@ -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 });
}

View File

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

View File

@ -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}
/>
<div className="pt-8 md:pt-10 px-6">
@ -110,10 +114,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
</div>
</div>
<div className="flex items-center gap-2 self-end md:self-auto">
<div className="inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85">
<FaDownload size={16} className="text-foreground/85" />
<span>{formatCompactNumber(hack.downloads)}</span>
</div>
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
<HackOptionsMenu slug={hack.slug} canEdit={canEdit} />
</div>
</div>

View File

@ -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<number>(initialCount);
const [anim, setAnim] = React.useState<boolean>(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 (
<div
className={`inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85 transition-transform ${
anim ? "scale-110 shadow-md font-bold" : ""
}`}
>
<FaDownload size={16} className="text-foreground/85" />
<span>{formatCompactNumber(count)}</span>
</div>
);
}

View File

@ -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<HackActionsProps> = ({
@ -23,6 +26,8 @@ const HackActions: React.FC<HackActionsProps> = ({
baseRomId,
platform,
patchUrl,
patchId,
hackSlug,
}) => {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
const [file, setFile] = React.useState<File | null>(null);
@ -160,6 +165,29 @@ const HackActions: React.FC<HackActionsProps> = ({
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<DownloadEventDetail>("hack:patch-applied", { detail: { slug: hackSlug } }));
}
})
.catch(() => {});
}
} catch {}
} catch (e: any) {
setError(e?.message || "Failed to patch ROM");
setStatus("idle");

View File

@ -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
}

5
src/types/util.ts Normal file
View File

@ -0,0 +1,5 @@
export interface DownloadEventDetail {
slug: string;
}
export type DownloadEvent = CustomEvent<DownloadEventDetail>;

View File

@ -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;

View File

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