Use is_archive for checking hack archive status

This commit is contained in:
Jared Schoeny 2025-12-24 00:01:56 -10:00
parent b5a4e53368
commit 3d5db2ed3c
14 changed files with 71 additions and 56 deletions

View File

@ -59,15 +59,16 @@ export const getDownloadsSeriesAll = async ({ days = 30 }: { days?: number }): P
// Get hacks owned by user
const { data: ownedHacks } = await supa
.from("hacks")
.select("slug,created_by,current_patch,original_author,permission_from")
.select("slug,created_by,current_patch,original_author,permission_from,is_archive")
.is("is_archive", false)
.eq("created_by", user.id);
const ownedSlugs = (ownedHacks ?? []).map((h) => h.slug);
// Get archive hacks that user can edit as archiver
const { data: allArchiveHacks } = await supa
.from("hacks")
.select("slug,created_by,current_patch,original_author,permission_from")
.not("original_author", "is", null);
.select("slug,created_by,current_patch,original_author,permission_from,is_archive")
.eq("is_archive", true);
const accessibleArchiveSlugs: string[] = [];
if (allArchiveHacks) {
@ -167,7 +168,7 @@ export const getHackInsights = async ({ slug }: { slug: string }): Promise<HackI
if (!user) throw new Error("Unauthorized");
const { data: hack } = await supa
.from("hacks")
.select("slug,created_by,current_patch,created_at,original_author,permission_from")
.select("slug,created_by,current_patch,created_at,original_author,permission_from,is_archive")
.eq("slug", slug)
.maybeSingle();
if (!hack) throw new Error("Not found");

View File

@ -35,9 +35,8 @@ export async function getArchives(args: {
let query = serviceClient
.from("hacks")
.select("slug,title,original_author,base_rom,created_at,created_by,approved,permission_from,current_patch", { count: "exact" })
.not("original_author", "is", null)
.or("current_patch.is.null,permission_from.not.is.null")
.select("slug,title,original_author,base_rom,created_at,created_by,approved,permission_from,current_patch,is_archive", { count: "exact" })
.eq("is_archive", true)
.order(sortBy, { ascending: sortOrder === "asc" })
.range(offset, offset + limit - 1);
@ -82,6 +81,7 @@ export async function getArchives(args: {
creator_username: usernameById.get(h.created_by as string) || null,
approved: h.approved,
current_patch: h.current_patch,
is_archive: h.is_archive,
}));
return {
@ -110,7 +110,7 @@ export async function deleteArchive(slug: string) {
// Verify it's an Archive hack
const { data: hack } = await supabase
.from("hacks")
.select("slug, original_author, current_patch")
.select("slug, is_archive")
.eq("slug", slug)
.maybeSingle();
@ -118,7 +118,7 @@ export async function deleteArchive(slug: string) {
return { ok: false, error: "Archive not found" } as const;
}
if (hack.original_author == null || hack.current_patch != null) {
if (!hack.is_archive) {
return { ok: false, error: "This is not an Archive hack" } as const;
}

View File

@ -84,7 +84,8 @@ export default async function DashboardPage() {
const { data: hacks } = await supa
.from("hacks")
.select("slug,title,approved,updated_at,downloads,current_patch(id,version),created_at,original_author")
.select("slug,title,approved,updated_at,downloads,current_patch(id,version),created_at,original_author,is_archive")
.is("is_archive", false)
.eq("created_by", user.id)
.order("updated_at", { ascending: false });

View File

@ -34,23 +34,22 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
// Build base query for hacks (public/anon view: only approved hacks)
let query = supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at")
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at,is_archive")
.eq("approved", true);
// Apply sorting based on sort type
if (sort === "popular") {
// 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.
// Archives are defined by the `is_archive` flag, so we order by that after downloads.
query = query
.order("downloads", { ascending: false })
.order("current_patch", { ascending: false, nullsFirst: false });
.order("is_archive", { ascending: true });
} else if (sort === "trending") {
// For trending, we'll fetch all and calculate scores in JS
// Still order by downloads first for efficiency
// Still order by downloads first for efficiency, then `is_archive` to keep non-archives first.
query = query
.order("downloads", { ascending: false })
.order("current_patch", { ascending: false, nullsFirst: false });
.order("is_archive", { ascending: true });
} else if (sort === "updated") {
// Will sort by current patch published_at in JS after fetching patches
} else if (sort === "alphabetical") {
@ -224,7 +223,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null;
publishedAtBySlug.set(r.slug, publishedAt);
} else {
mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release");
mappedVersions.set(r.slug, r.is_archive ? "Archive" : "Pre-release");
publishedAtBySlug.set(r.slug, null);
}
});
@ -256,7 +255,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
version: mappedVersions.get(r.slug) || "Pre-release",
summary: r.summary,
description: r.description,
isArchive: r.original_author != null && r.current_patch === null,
is_archive: r.is_archive,
}));
// Sort by current patch published_at for "updated" sort
@ -275,8 +274,8 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
// Secondary sort: when times are equal, push archives to end
if (aTime === bTime) {
if (a.isArchive && !b.isArchive) return 1;
if (!a.isArchive && b.isArchive) return -1;
if (a.is_archive && !b.is_archive) return 1;
if (!a.is_archive && b.is_archive) return -1;
}
return bTime - aTime; // Descending order (newest first)
@ -291,8 +290,8 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
// Secondary sort: push archives to end
if (scoreA === scoreB) {
if (a.isArchive && !b.isArchive) return 1;
if (!a.isArchive && b.isArchive) return -1;
if (a.is_archive && !b.is_archive) return 1;
if (!a.is_archive && b.is_archive) return -1;
}
return scoreB - scoreA; // Descending order

View File

@ -20,7 +20,7 @@ export default async function EditHackPage({ params }: EditPageProps) {
const { data: hack } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author,permission_from")
.select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive")
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();

View File

@ -50,7 +50,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
const supabase = await createClient();
const { data: hack } = await supabase
.from("hacks")
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch,permission_from")
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch,permission_from,is_archive")
.eq("slug", slug)
.maybeSingle();
if (!hack) return { title: "Hack not found" };
@ -120,7 +120,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
const supabase = await createClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language")
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return notFound();
@ -156,15 +156,24 @@ export default async function HackDetail({ params }: HackDetailProps) {
.maybeSingle();
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
// Get other approved hacks by the same author
const { data: otherHacks } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
// Get other approved hacks by the same author (non-archive hacks only)
let otherHacks: {
slug: string;
title: string;
summary: string;
}[] = [];
if (!hack.is_archive) {
const { data: otherHacksData } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
otherHacks = otherHacksData ?? [];
}
const {
data: { user },

View File

@ -12,7 +12,7 @@ export default async function HackStatsPage({ params: { slug } }: { params: { sl
const { data: hack } = await supa
.from("hacks")
.select("slug,created_by,title,original_author,current_patch,permission_from")
.select("slug,created_by,title,original_author,current_patch,permission_from,is_archive")
.eq("slug", slug)
.maybeSingle();
if (!hack) notFound();

View File

@ -34,7 +34,7 @@ export async function updateHack(args: {
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@ -125,7 +125,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@ -208,7 +208,7 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@ -252,7 +252,7 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;

View File

@ -22,9 +22,10 @@ export default async function Home() {
// Fetch top 6 approved hacks ordered by downloads
const { data: popularHacks } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author")
.select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author,is_archive")
.eq("approved", true)
.not("current_patch", "is", null)
.is("is_archive", false)
.order("downloads", { ascending: false })
.limit(6);
@ -111,7 +112,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,
is_archive: false,
}));
}
return (

View File

@ -49,15 +49,15 @@ export async function prepareSubmission(formData: FormData) {
const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean) || [];
const original_author = (formData.get("original_author") as string)?.trim() || null;
const permission_from = (formData.get("permission_from") as string)?.trim() || null;
const isArchive = formData.get("isArchive") === "true";
const is_archive = formData.get("is_archive") === "true";
// For archives, version is not required; for regular hacks, it is
if (!title || !summary || !description || !base_rom || !language || (!isArchive && !version)) {
if (!title || !summary || !description || !base_rom || !language || (!is_archive && !version)) {
return { ok: false, error: "Missing required fields" } as const;
}
// For archives, original_author is required
if (isArchive && !original_author) {
if (is_archive && !original_author) {
return { ok: false, error: "Original author is required for Archive hacks" } as const;
}
@ -86,7 +86,8 @@ export async function prepareSubmission(formData: FormData) {
downloads: 0,
box_art,
social_links,
approved: isArchive, // Auto-approve archives
approved: is_archive, // Auto-approve archives
is_archive,
patch_url: "",
original_author: original_author || null,
permission_from: permission_from || null,
@ -143,7 +144,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@ -182,7 +183,7 @@ export async function presignPatchAndSaveCovers(args: {
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@ -224,7 +225,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, title, current_patch, original_author, permission_from")
.select("slug, created_by, title, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;

View File

@ -18,6 +18,7 @@ type Archive = {
creator_username: string | null;
approved: boolean;
current_patch: number | null;
is_archive: boolean;
};
type ArchivesData =

View File

@ -436,7 +436,7 @@ export default function HackSubmitForm({
if (github) fd.set('github', github);
if (tags.length) fd.set('tags', tags.join(','));
if (isArchive) {
fd.set('isArchive', 'true');
fd.set('is_archive', 'true');
}
if (originalAuthor) {
fd.set('original_author', originalAuthor);
@ -632,6 +632,7 @@ export default function HackSubmitForm({
: undefined,
createdAt: new Date().toISOString(),
patchUrl: "",
is_archive: isArchive,
};
const hasBaseRom = !!baseRom.trim();

View File

@ -24,11 +24,11 @@ export interface HackCardAttributes {
version: string;
summary?: string;
description?: string;
isArchive?: boolean;
is_archive: boolean;
};
export default function HackCard({ hack, clickable = true, className = "" }: { hack: HackCardAttributes; clickable?: boolean; className?: string }) {
const isArchive = !!hack.isArchive;
const isArchive = hack.is_archive;
const { isLinked, hasPermission, hasCached } = useBaseRoms();
const match = baseRoms.find((r) => r.id === hack.baseRomId);
const baseId = match?.id ?? undefined;

View File

@ -2,6 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { checkUserRoles } from "@/utils/user";
type HackWithArchiveFields = {
is_archive: boolean;
original_author: string | null;
current_patch: number | null;
permission_from?: string | null;
@ -9,24 +10,24 @@ type HackWithArchiveFields = {
};
/**
* Check if a hack is an informational archive (has original_author but no patch)
* Check if a hack is an informational archive (archive flag set, no patch)
*/
export function isInformationalArchiveHack(hack: HackWithArchiveFields): boolean {
return hack.original_author != null && hack.current_patch === null;
return hack.is_archive === true && hack.current_patch === null;
}
/**
* Check if a hack is a downloadable archive (has original_author, patch, and permission_from)
* Check if a hack is a downloadable archive (archive flag set with patch and permission)
*/
export function isDownloadableArchiveHack(hack: HackWithArchiveFields): boolean {
return hack.original_author != null && hack.current_patch !== null && hack.permission_from != null;
return hack.is_archive === true && hack.current_patch !== null && hack.permission_from != null;
}
/**
* Check if a hack is any type of archive (informational or downloadable)
*/
export function isArchiveHack(hack: HackWithArchiveFields): boolean {
return isInformationalArchiveHack(hack) || isDownloadableArchiveHack(hack);
return hack.is_archive === true;
}
/**