Add archive distinction to HackCard and DiscoverBrowser

This commit is contained in:
Jared Schoeny 2025-12-03 21:38:26 -10:00
parent 06318ef715
commit 147d25342e
3 changed files with 57 additions and 34 deletions

View File

@ -6,6 +6,7 @@ import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import { sortOrderedTags } from "@/utils/format";
import { getCoverSignedUrls } from "@/app/hack/actions";
import { HackCardAttributes } from "@/components/HackCard";
export const metadata: Metadata = {
alternates: {
@ -27,7 +28,7 @@ export default async function Home() {
.order("downloads", { ascending: false })
.limit(6);
let hackData: any[] = [];
let hackData: HackCardAttributes[] = [];
if (popularHacks && popularHacks.length > 0) {
const slugs = popularHacks.map((h) => h.slug);
@ -110,6 +111,7 @@ export default async function Home() {
version: mappedVersions.get(r.slug) || "Pre-release",
summary: r.summary,
description: r.description,
isArchive: r.original_author != null && r.current_patch === null,
}));
}
return (

View File

@ -13,6 +13,7 @@ import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { sortOrderedTags, OrderedTag } from "@/utils/format";
import { getCoverSignedUrls } from "@/app/hack/actions";
import { HackCardAttributes } from "@/components/HackCard";
export default function DiscoverBrowser() {
@ -21,7 +22,7 @@ export default function DiscoverBrowser() {
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
const [sort, setSort] = React.useState("popular");
const [hacks, setHacks] = React.useState<any[]>([]);
const [hacks, setHacks] = React.useState<HackCardAttributes[]>([]);
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
const [ungroupedTags, setUngroupedTags] = React.useState<string[]>([]);
const [loadingHacks, setLoadingHacks] = React.useState(true);
@ -46,19 +47,24 @@ export default function DiscoverBrowser() {
const run = async () => {
setLoadingHacks(true);
setLoadingTags(true);
let orderBy: string | undefined = undefined;
let query = supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author");
if (sort === "popular") {
orderBy = "downloads";
// When sorting by popularity, always show non-archive hacks first.
// Archives are defined as rows where original_author IS NOT NULL and current_patch IS NULL,
// so ordering by current_patch with NULLS LAST effectively pushes archives to the end.
query = query
.order("downloads", { ascending: false })
.order("current_patch", { ascending: false, nullsFirst: false });
} else if (sort === "updated") {
orderBy = "updated_at";
query = query.order("updated_at", { ascending: false });
} else {
orderBy = "created_at";
query = query.order("created_at", { ascending: false });
}
const { data: rows } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author")
.order(orderBy, { ascending: false });
const { data: rows } = await query;
const slugs = (rows || []).map((r) => r.slug);
const { data: coverRows } = await supabase
.from("hack_covers")
@ -132,6 +138,7 @@ export default function DiscoverBrowser() {
version: mappedVersions.get(r.slug) || "Pre-release",
summary: r.summary,
description: r.description,
isArchive: r.original_author != null && r.current_patch === null,
}));
setHacks(mapped);
@ -177,15 +184,15 @@ export default function DiscoverBrowser() {
}
// AND filter across selected tags: hack must include all selectedTags
if (selectedTags.length > 0) {
out = out.filter((h) => selectedTags.every((t) => h.tags.includes(t)));
out = out.filter((h) => selectedTags.every((t) => h.tags.some((tag) => tag.name === t)));
}
// OR filter across base roms: hack's baseRomId must be in selectedBaseRoms
if (selectedBaseRoms.length > 0) {
out = out.filter((h) => selectedBaseRoms.includes(h.baseRomId));
out = out.filter((h) => h.baseRomId && selectedBaseRoms.includes(h.baseRomId));
}
// Filter to hacks whose base ROM is ready (linked with permission or cached)
if (onlyReady) {
out = out.filter((h) => readyBaseRomIds.has(h.baseRomId));
out = out.filter((h) => !h.isArchive && h.baseRomId && readyBaseRomIds.has(h.baseRomId));
}
return out;
}, [hacks, query, selectedTags, selectedBaseRoms, onlyReady, readyBaseRomIds]);

View File

@ -11,8 +11,9 @@ import useEmblaCarousel from "embla-carousel-react";
import { usePathname } from "next/navigation";
import { FaRegImages } from "react-icons/fa6";
import { ImDownload } from "react-icons/im";
import { FaArchive } from "react-icons/fa";
type CardHack = {
export interface HackCardAttributes {
slug: string;
title: string;
author: string;
@ -23,15 +24,19 @@ type CardHack = {
version: string;
summary?: string;
description?: string;
isArchive?: boolean;
};
export default function HackCard({ hack, clickable = true, className = "" }: { hack: CardHack; clickable?: boolean; className?: string }) {
export default function HackCard({ hack, clickable = true, className = "" }: { hack: HackCardAttributes; clickable?: boolean; className?: string }) {
const isArchive = !!hack.isArchive;
const { isLinked, hasPermission, hasCached } = useBaseRoms();
const match = baseRoms.find((r) => r.id === hack.baseRomId);
const baseId = match?.id ?? undefined;
const baseName = match?.name ?? undefined;
const linked = baseId ? isLinked(baseId) : false;
const ready = baseId ? hasPermission(baseId) || hasCached(baseId) : false;
// Only compute base ROM readiness for non-archive hacks
const linked = !isArchive && baseId ? isLinked(baseId) : false;
const ready = !isArchive && baseId ? hasPermission(baseId) || hasCached(baseId) : false;
const images = (hack.covers && hack.covers.length > 0 ? hack.covers : []).filter(Boolean);
const isCarousel = images.length > 1;
const pathname = usePathname();
@ -121,7 +126,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
) : null}
<div className="absolute left-3 top-3 z-10 flex gap-2">
{hack.tags.slice(0, 2).map((t) => (
{hack.tags.slice(0, isArchive ? 3 : 2).map((t) => (
<span
key={t.name}
className="rounded-full px-2 py-0.5 text-xs ring-1 ring-foreground/20 dark:ring-foreground/30 bg-background/70 text-foreground/90 backdrop-blur-md"
@ -129,18 +134,25 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
{t.name}
</span>
))}
<span
className={`rounded-full px-2 py-0.5 text-xs ring-1 backdrop-blur-md ${
ready
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: linked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}
>
{ready ? "Ready" : linked ? "Permission needed" : "Base ROM needed"}
</span>
{!isArchive && (
<span
className={`rounded-full px-2 py-0.5 text-xs ring-1 backdrop-blur-md ${
ready
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: linked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}
>
{ready ? "Ready" : linked ? "Permission needed" : "Base ROM needed"}
</span>
)}
</div>
{isArchive && (
<div className="absolute right-3 top-3 z-10">
<FaArchive size={20} className="text-foreground/60" />
</div>
)}
{isCarousel && (
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
{images.map((_, i) => (
@ -164,8 +176,8 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="min-w-0 w-full">
<div className={`flex items-center gap-2 ${isArchive ? "justify-between" : "justify-start"}`}>
<h3 className="line-clamp-1 text-[15px] font-semibold tracking-tight">
{hack.title}
</h3>
@ -175,10 +187,12 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
</div>
<p className="mt-1 text-xs text-foreground/60">By {hack.author}</p>
</div>
<div className="flex items-center gap-1 text-sm text-foreground/70">
{!isArchive && (
<div className="flex items-center gap-1 text-sm text-foreground/70">
<ImDownload size={16} />
<span>{formatCompactNumber(hack.downloads)}</span>
</div>
<span>{formatCompactNumber(hack.downloads)}</span>
</div>
)}
</div>
<p className="mt-2 line-clamp-2 text-sm text-foreground/70">
{(() => {