Improve ArchivesList to include archives with patches

This commit is contained in:
Jared Schoeny 2025-12-05 22:52:09 -10:00
parent 549c59246b
commit a4c9474f7f
2 changed files with 102 additions and 15 deletions

View File

@ -1,6 +1,6 @@
"use server";
import { createClient } from "@/utils/supabase/server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
export async function getArchives(args: {
page?: number;
@ -8,6 +8,7 @@ export async function getArchives(args: {
search?: string;
sortBy?: "title" | "created_at" | "original_author";
sortOrder?: "asc" | "desc";
filter?: "all" | "downloadable" | "informational";
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@ -21,21 +22,35 @@ export async function getArchives(args: {
return { ok: false, error: "Forbidden" } as const;
}
// Use service role client to bypass RLS entirely and avoid recursion
const serviceClient = await createServiceClient();
const page = args.page || 1;
const limit = args.limit || 50;
const offset = (page - 1) * limit;
const search = args.search?.trim() || "";
const sortBy = args.sortBy || "created_at";
const sortOrder = args.sortOrder || "desc";
const filter = args.filter || "all";
let query = supabase
let query = serviceClient
.from("hacks")
.select("slug,title,original_author,base_rom,created_at,created_by,approved", { count: "exact" })
.select("slug,title,original_author,base_rom,created_at,created_by,approved,permission_from,current_patch", { count: "exact" })
.not("original_author", "is", null)
.is("current_patch", null)
.or("current_patch.is.null,permission_from.not.is.null")
.order(sortBy, { ascending: sortOrder === "asc" })
.range(offset, offset + limit - 1);
// Apply archive type filter
if (filter === "downloadable") {
// Downloadable: permission_from is not null AND current_patch is not null
query = query.not("permission_from", "is", null).not("current_patch", "is", null);
} else if (filter === "informational") {
// Informational: current_patch is null
query = query.is("current_patch", null);
}
// "all" doesn't need additional filtering
if (search) {
query = query.or(`title.ilike.%${search}%,original_author.ilike.%${search}%,base_rom.ilike.%${search}%`);
}
@ -60,11 +75,13 @@ export async function getArchives(args: {
slug: h.slug,
title: h.title,
original_author: h.original_author,
permission_from: h.permission_from,
base_rom: h.base_rom,
created_at: h.created_at,
created_by: h.created_by,
creator_username: usernameById.get(h.created_by as string) || null,
approved: h.approved,
current_patch: h.current_patch,
}));
return {

View File

@ -2,7 +2,7 @@
import React from "react";
import Link from "next/link";
import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader } from "react-icons/fi";
import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader, FiDownload, FiInfo, FiBarChart2 } from "react-icons/fi";
import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions";
import { baseRoms } from "@/data/baseRoms";
@ -10,11 +10,13 @@ type Archive = {
slug: string;
title: string;
original_author: string | null;
permission_from: string | null;
base_rom: string;
created_at: string;
created_by: string;
creator_username: string | null;
approved: boolean;
current_patch: number | null;
};
type ArchivesData =
@ -29,6 +31,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [sortBy, setSortBy] = React.useState<"title" | "created_at" | "original_author">("created_at");
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("desc");
const [filter, setFilter] = React.useState<"all" | "downloadable" | "informational">("all");
const [deletingSlug, setDeletingSlug] = React.useState<string | null>(null);
// Debounce search input
@ -41,17 +44,22 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
return () => clearTimeout(timer);
}, [search]);
// Reset to first page when filter changes
React.useEffect(() => {
setPage(1);
}, [filter]);
const loadArchives = React.useCallback(async () => {
setLoading(true);
try {
const result = await getArchives({ page, limit: 50, search: debouncedSearch, sortBy, sortOrder });
const result = await getArchives({ page, limit: 50, search: debouncedSearch, sortBy, sortOrder, filter });
setData(result);
} catch (err: any) {
setData({ ok: false, error: err?.message || "Failed to load archives" });
} finally {
setLoading(false);
}
}, [page, debouncedSearch, sortBy, sortOrder]);
}, [page, debouncedSearch, sortBy, sortOrder, filter]);
React.useEffect(() => {
loadArchives();
@ -110,6 +118,15 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as "all" | "downloadable" | "informational")}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="all">All Archives</option>
<option value="downloadable">Downloadable</option>
<option value="informational">Informational</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
@ -144,10 +161,12 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
<>
{/* Desktop header */}
<div className="hidden lg:grid grid-cols-12 gap-4 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
<div className="col-span-4">Title</div>
<div className="col-span-1 text-center">Type</div>
<div className="col-span-3">Title</div>
<div className="col-span-2">Original Author</div>
<div className="col-span-2">Base ROM</div>
<div className="col-span-2">Archived by</div>
<div className="col-span-2">Permission From</div>
<div className="col-span-1">Base ROM</div>
<div className="col-span-1 text-xs">Archived by</div>
<div className="col-span-2 text-right">Actions</div>
</div>
<div className="divide-y divide-[var(--border)]">
@ -155,12 +174,28 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
const baseRom = baseRoms.find((r) => r.id === archive.base_rom);
const createdDate = new Date(archive.created_at).toLocaleDateString();
const creator = archive.creator_username ? `@${archive.creator_username}` : "Unknown";
const isDownloadable = archive.permission_from != null && archive.current_patch != null;
const isInformational = archive.current_patch == null;
return (
<div key={archive.slug} className="px-4 py-3 text-sm">
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center gap-4">
<Link href={`/hack/${archive.slug}`} target="_blank" className="group flex items-center gap-3 col-span-4 min-w-0 hover:text-foreground">
<div className="col-span-1 flex items-center justify-center">
{isDownloadable && (
<FiDownload
className="h-4 w-4 text-blue-600 dark:text-blue-400"
title="Downloadable Archive"
/>
)}
{isInformational && (
<FiInfo
className="h-4 w-4 text-gray-600 dark:text-gray-400"
title="Informational Archive"
/>
)}
</div>
<Link href={`/hack/${archive.slug}`} target="_blank" className="group flex items-center gap-3 col-span-3 min-w-0 hover:text-foreground">
<div className="flex flex-col items-start min-w-0">
<div className="truncate font-medium group-hover:underline">{archive.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{archive.slug}</div>
@ -168,12 +203,22 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
</Link>
<div className="col-span-2 text-foreground/80">{archive.original_author || "—"}</div>
<div className="col-span-2 text-foreground/80">{baseRom?.name || archive.base_rom}</div>
<div className="col-span-2 text-foreground/80">
<div>{creator}</div>
<div className="text-xs text-foreground/60">{createdDate}</div>
<div className="col-span-2 text-foreground/80">{archive.permission_from || "—"}</div>
<div className="col-span-1 text-foreground/80">{baseRom?.name || archive.base_rom}</div>
<div className="col-span-1 text-foreground/80 text-xs">
<div className="truncate">{creator}</div>
<div className="text-[10px] text-foreground/60">{createdDate}</div>
</div>
<div className="col-span-2 flex items-center justify-end gap-2">
{isDownloadable && (
<Link
href={`/hack/${archive.slug}/stats`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
title="View Stats"
>
<FiBarChart2 className="h-4 w-4" />
</Link>
)}
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
@ -198,21 +243,46 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
{/* Mobile card */}
<div className="lg:hidden flex flex-col gap-2">
<div className="group flex justify-between items-center">
<div className="flex items-center gap-2">
{isDownloadable && (
<FiDownload
className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0"
title="Downloadable Archive"
/>
)}
{isInformational && (
<FiInfo
className="h-4 w-4 text-gray-600 dark:text-gray-400 flex-shrink-0"
title="Informational Archive"
/>
)}
<Link href={`/hack/${archive.slug}`} target="_blank">
<div className="text-lg font-bold group-hover:underline">{archive.title}</div>
<div className="text-xs text-foreground/60 group-hover:underline">/{archive.slug}</div>
</Link>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
<span className="font-bold">Author: {archive.original_author || "—"}</span>
<span>|</span>
{archive.permission_from && <span className="font-bold">Permission: {archive.permission_from || "—"}</span>}
{archive.permission_from && <span>|</span>}
<span className="font-bold">Base: {baseRom?.name || archive.base_rom}</span>
</div>
<div className="flex flex-wrap items-center text-xs italic text-foreground/60">
Archived by {creator} on {createdDate}
</div>
<div className="flex items-center gap-2 mt-2">
{isDownloadable && (
<Link
href={`/hack/${archive.slug}/stats`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs hover:bg-black/5 dark:hover:bg-white/10"
>
<FiBarChart2 className="h-3 w-3" />
Stats
</Link>
)}
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs hover:bg-black/5 dark:hover:bg-white/10"