From 6e41bb99f818d19d4a31cd8b72edfdb40d2a2df7 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Thu, 9 Jul 2026 23:35:10 -0600 Subject: [PATCH] Label new tags for creators + tag categories migration fix (#64) * Add ability to see new tags since last update * Fix some bugs and edge cases * Add migration fix to appease ci --- src/app/hack/[slug]/edit/page.tsx | 6 +- src/app/hack/actions.ts | 10 ++++ src/components/Hack/HackEditForm.tsx | 5 +- src/components/Hack/HackForm.tsx | 8 ++- src/components/Hack/HackSubmitForm.tsx | 2 +- src/components/Submit/TagSelector.tsx | 56 ++++++++++++++++--- src/data/tags.ts | 5 +- src/types/catalogTag.ts | 1 + src/types/db.ts | 6 ++ .../20260706161539_created_at_for_tags.sql | 18 ++++++ ...0710052853_fix_bad_tag_category_change.sql | 11 ++++ 11 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260706161539_created_at_for_tags.sql create mode 100644 supabase/migrations/20260710052853_fix_bad_tag_category_change.sql 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 $$;