diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx
index 4b372b6..96fa94a 100644
--- a/src/app/hack/[slug]/edit/page.tsx
+++ b/src/app/hack/[slug]/edit/page.tsx
@@ -22,11 +22,13 @@ export default async function EditHackPage({ params }: EditPageProps) {
const { data: hack } = await supabase
.from("hacks")
- .select("slug,title,summary,description,base_rom,language,completion_status,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive")
+ .select("slug,title,summary,description,base_rom,language,completion_status,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive,tags_updated_at")
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
+ const tagsUpdatedAt = new Date(hack.tags_updated_at);
+
// Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack
const permission = await checkEditPermission(hack, user!.id, supabase);
const { isInformationalArchive, isDownloadableArchive, isArchive } = permission;
@@ -121,7 +123,7 @@ export default async function EditHackPage({ params }: EditPageProps) {
-
+
);
diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts
index e11f749..b4c8cf6 100644
--- a/src/app/hack/actions.ts
+++ b/src/app/hack/actions.ts
@@ -111,6 +111,16 @@ export async function updateHack(args: {
.upsert(rows, { onConflict: "hack_slug,tag_id" });
if (upErr) return { ok: false, error: upErr.message } as const;
}
+
+ // Update tags_updated_at if anything was added or removed (but not reordered)
+ const tagsUpdated = toRemove.length > 0 || desiredIds.some((id) => !currentIds.has(id));
+ if (tagsUpdated) {
+ const { error: upErr } = await supabase
+ .from("hacks")
+ .update({ tags_updated_at: new Date().toISOString() })
+ .eq("slug", args.slug);
+ if (upErr) return { ok: false, error: upErr.message } as const;
+ }
}
revalidateTag(`hack:${args.slug}:metadata`);
diff --git a/src/components/Hack/HackEditForm.tsx b/src/components/Hack/HackEditForm.tsx
index 30ba2c8..44cb0b6 100644
--- a/src/components/Hack/HackEditForm.tsx
+++ b/src/components/Hack/HackEditForm.tsx
@@ -36,9 +36,10 @@ interface HackEditFormProps {
coverKeys: string[]; // storage keys for covers in order
signedCoverUrls?: string[]; // optional signed URLs aligned to keys
};
+ tagsUpdatedAt: Date;
}
-export default function HackEditForm({ slug, initial, catalogTags }: HackEditFormProps) {
+export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt }: HackEditFormProps) {
const supabase = createClient();
const MAX_COVERS = 10;
const [title, setTitle] = React.useState(initial.title);
@@ -365,7 +366,7 @@ export default function HackEditForm({ slug, initial, catalogTags }: HackEditFor
)}
-
+
diff --git a/src/components/Hack/HackForm.tsx b/src/components/Hack/HackForm.tsx
index b8d3f2e..47e4450 100644
--- a/src/components/Hack/HackForm.tsx
+++ b/src/components/Hack/HackForm.tsx
@@ -21,6 +21,7 @@ interface HackFormEditProps {
slug: string;
initial: React.ComponentProps["initial"];
catalogTags: CatalogTagRow[];
+ tagsUpdatedAt: Date;
}
export type HackFormProps = HackFormCreateProps | HackFormEditProps;
@@ -35,7 +36,12 @@ export default function HackForm(props: HackFormProps) {
catalogTags={props.catalogTags}
/>;
}
- return ;
+ return ;
}
diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx
index a5544ca..d3db356 100644
--- a/src/components/Hack/HackSubmitForm.tsx
+++ b/src/components/Hack/HackSubmitForm.tsx
@@ -902,7 +902,7 @@ export default function HackSubmitForm({
-
+
diff --git a/src/components/Submit/TagSelector.tsx b/src/components/Submit/TagSelector.tsx
index 7b35aed..24695b1 100644
--- a/src/components/Submit/TagSelector.tsx
+++ b/src/components/Submit/TagSelector.tsx
@@ -27,6 +27,7 @@ export interface TagSelectorProps {
onChange: (next: string[]) => void;
/** When set, skips client Supabase fetch (use server-cached catalog). */
catalogTags?: CatalogTagRow[];
+ newTagsCutoff: Date | null;
}
type CategoryIconType = React.ComponentType> | null;
@@ -112,7 +113,15 @@ function SortableSelectedTag({
);
}
-export default function TagSelector({ value, onChange, catalogTags }: TagSelectorProps) {
+function compareTags(a: TagRow, b: TagRow, newTagsCutoff: Date | null): number {
+ const aNew = !!(a.created_at && newTagsCutoff && new Date(a.created_at) > newTagsCutoff);
+ const bNew = !!(b.created_at && newTagsCutoff && new Date(b.created_at) > newTagsCutoff);
+ if (aNew && !bNew) return -1;
+ if (!aNew && bNew) return 1;
+ return (b.popularity - a.popularity) || a.name.localeCompare(b.name);
+}
+
+export default function TagSelector({ value, onChange, catalogTags, newTagsCutoff }: TagSelectorProps) {
const supabase = createClient();
const [query, setQuery] = React.useState("");
const [allTags, setAllTags] = React.useState(() => catalogTags ?? []);
@@ -140,6 +149,16 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
setCategoriesPaneFocused(true);
}, []);
+ const categoriesWithNewTags = React.useMemo(() => {
+ const categories = new Set();
+ for (const t of allTags) {
+ if (t.category && t.created_at && newTagsCutoff && new Date(t.created_at) > newTagsCutoff) {
+ categories.add(t.category);
+ }
+ }
+ return Array.from(categories);
+ }, [allTags, newTagsCutoff]);
+
React.useEffect(() => {
if (catalogTags !== undefined) return;
let cancelled = false;
@@ -148,14 +167,18 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
setLoading(true);
const { data } = await supabase
.from("tags")
- .select("id,name,category,usage: hack_tags (count)");
+ .select("id,name,category,created_at,usage: hack_tags (count)");
const rows: TagRow[] = (data || []).map((t: any) => ({
id: t.id,
name: t.name,
category: t.category ?? null,
popularity: t.usage?.[0]?.count || 0,
+ created_at: t.created_at ?? null,
}));
- rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
+ // Put new tags first, then sort by popularity and name
+ rows.sort((a, b) => {
+ return compareTags(a, b, newTagsCutoff);
+ });
if (!cancelled) setAllTags(rows);
} finally {
if (!cancelled) setLoading(false);
@@ -164,7 +187,7 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
return () => {
cancelled = true;
};
- }, [catalogTags, supabase]);
+ }, [catalogTags, supabase, newTagsCutoff]);
const grouped = React.useMemo(() => {
const map = new Map();
@@ -178,11 +201,17 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
map.set(t.category, arr);
}
}
- // sort tags inside categories
- for (const [, arr] of map) arr.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
- advanced.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
+ // Put new tags first, then sort by popularity and name
+ for (const [, arr] of map) {
+ arr.sort((a, b) => {
+ return compareTags(a, b, newTagsCutoff);
+ });
+ }
+ advanced.sort((a, b) => {
+ return compareTags(a, b, newTagsCutoff);
+ });
return { categories: Array.from(map.keys()).sort((a, b) => a.localeCompare(b)), byCat: map, advanced };
- }, [allTags]);
+ }, [allTags, newTagsCutoff]);
// Filter categories and tags by query; hide categories with zero results. Keep selected tags visible.
const filtered = React.useMemo(() => {
@@ -431,7 +460,13 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
: 'hover:bg-black/5 dark:hover:bg-white/10'
}`}
>
- {Icon ? : null}{cat}
+
+ {Icon ? : null}
+ {cat}
+ {newTagsCutoff && categoriesWithNewTags.includes(cat) && (
+ New
+ )}
+
);})}
{filtered.advanced.length > 0 && (
@@ -499,6 +534,9 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
className={`flex items-center justify-between rounded px-2 py-1.5 text-sm ${activeTagIndex === idx ? 'bg-black/5 dark:bg-white/10' : 'hover:bg-black/5 dark:hover:bg-white/10'}`}
>
{t.name}
+ {t.created_at && newTagsCutoff && new Date(t.created_at) > newTagsCutoff && (
+ New
+ )}
))}
diff --git a/src/data/tags.ts b/src/data/tags.ts
index f8f8e08..fc313f4 100644
--- a/src/data/tags.ts
+++ b/src/data/tags.ts
@@ -15,6 +15,7 @@ function mapAndSortTagRows(data: unknown): CatalogTagRow[] {
name: t.name as string,
category: (t.category ?? null) as string | null,
popularity: t.usage?.[0]?.count || 0,
+ created_at: t.created_at as string | null,
}));
rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
return rows;
@@ -26,11 +27,11 @@ export async function getCachedTagsWithUsage(): Promise {
const supabase = await createServiceClient();
const { data, error } = await supabase
.from("tags")
- .select("id,name,category,usage: hack_tags (count)");
+ .select("id,name,category,created_at,usage: hack_tags (count)");
if (error) throw error;
return mapAndSortTagRows(data);
},
- ["tags-catalog-v1"],
+ ["tags-catalog-v2"],
{ revalidate: TAGS_CATALOG_REVALIDATE_SECONDS, tags: [TAGS_CATALOG_CACHE_TAG] }
);
return runner();
diff --git a/src/types/catalogTag.ts b/src/types/catalogTag.ts
index 8332165..77551b3 100644
--- a/src/types/catalogTag.ts
+++ b/src/types/catalogTag.ts
@@ -4,4 +4,5 @@ export type CatalogTagRow = {
name: string;
category: string | null;
popularity: number;
+ created_at: string | null;
};
diff --git a/src/types/db.ts b/src/types/db.ts
index 3523e30..0582313 100644
--- a/src/types/db.ts
+++ b/src/types/db.ts
@@ -159,6 +159,7 @@ export type Database = {
slug: string
social_links: Json | null
summary: string
+ tags_updated_at: string
title: string
updated_at: string | null
verification_contact_info: string | null
@@ -194,6 +195,7 @@ export type Database = {
slug: string
social_links?: Json | null
summary: string
+ tags_updated_at?: string
title: string
updated_at?: string | null
verification_contact_info?: string | null
@@ -229,6 +231,7 @@ export type Database = {
slug?: string
social_links?: Json | null
summary?: string
+ tags_updated_at?: string
title?: string
updated_at?: string | null
verification_contact_info?: string | null
@@ -425,16 +428,19 @@ export type Database = {
tags: {
Row: {
category: Database["public"]["Enums"]["Tag Categories"] | null
+ created_at: string | null
id: number
name: string
}
Insert: {
category?: Database["public"]["Enums"]["Tag Categories"] | null
+ created_at?: string | null
id?: number
name: string
}
Update: {
category?: Database["public"]["Enums"]["Tag Categories"] | null
+ created_at?: string | null
id?: number
name?: string
}
diff --git a/supabase/migrations/20260706161539_created_at_for_tags.sql b/supabase/migrations/20260706161539_created_at_for_tags.sql
new file mode 100644
index 0000000..d338134
--- /dev/null
+++ b/supabase/migrations/20260706161539_created_at_for_tags.sql
@@ -0,0 +1,18 @@
+-- All existing tags will have null created_at values
+-- but future tags will default to now()
+alter table "public"."tags"
+ add column "created_at" timestamp with time zone;
+alter table "public"."tags"
+ alter column "created_at" set default now();
+
+-- All existing hacks will use created_at for tags_updated_at
+-- but future hacks will default to now()
+alter table "public"."hacks"
+ add column "tags_updated_at" timestamp with time zone;
+update "public"."hacks"
+ set tags_updated_at = created_at
+ where tags_updated_at is null;
+alter table "public"."hacks"
+ alter column "tags_updated_at" set default now();
+alter table "public"."hacks"
+ alter column "tags_updated_at" set not null;
diff --git a/supabase/migrations/20260710052853_fix_bad_tag_category_change.sql b/supabase/migrations/20260710052853_fix_bad_tag_category_change.sql
new file mode 100644
index 0000000..ead132a
--- /dev/null
+++ b/supabase/migrations/20260710052853_fix_bad_tag_category_change.sql
@@ -0,0 +1,11 @@
+DO $$ BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM pg_enum e
+ JOIN pg_type t ON e.enumtypid = t.oid
+ WHERE t.typname = 'Tag Categories'
+ AND e.enumlabel = 'Sprites'
+ ) THEN
+ ALTER TYPE public."Tag Categories" RENAME VALUE 'Sprites' TO 'Graphics';
+ END IF;
+END $$;