mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-09-07 08:26:07 -05:00
Add proper download counting functionality
This commit is contained in:
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>;
|
||||
Reference in New Issue
Block a user