mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Add proper download counting functionality
This commit is contained in:
parent
9287ddbace
commit
32d5af3c8e
31
src/app/api/patches/[id]/applied/route.ts
Normal file
31
src/app/api/patches/[id]/applied/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
37
src/components/Hack/DownloadsBadge.tsx
Normal file
37
src/components/Hack/DownloadsBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
5
src/types/util.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface DownloadEventDetail {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export type DownloadEvent = CustomEvent<DownloadEventDetail>;
|
||||
103
supabase/migrations/20251024202142_downloads_counter.sql
Normal file
103
supabase/migrations/20251024202142_downloads_counter.sql
Normal 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;
|
||||
|
||||
|
||||
|
|
@ -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();
|
||||
Loading…
Reference in New Issue
Block a user