Label new tags for creators + tag categories migration fix (#64)
Some checks are pending
Deploy Supabase Migrations to Production / migrate (push) Waiting to run

* Add ability to see new tags since last update

* Fix some bugs and edge cases

* Add migration fix to appease ci
This commit is contained in:
Jared Schoeny
2026-07-09 23:35:10 -06:00
committed by GitHub
parent 8ea523de3c
commit 6e41bb99f8
11 changed files with 111 additions and 17 deletions

View File

@@ -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) {
</div>
</div>
<div className="mt-4 lg:mt-8">
<HackForm mode="edit" slug={slug} initial={initial} catalogTags={catalogTags} />
<HackForm mode="edit" slug={slug} initial={initial} catalogTags={catalogTags} tagsUpdatedAt={tagsUpdatedAt} />
</div>
</div>
);

View File

@@ -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`);

View File

@@ -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
)}
</div>
<div className={`rounded-md ring-1 ring-inset ${tagsChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'ring-transparent'} p-1`}>
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} />
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} newTagsCutoff={tagsUpdatedAt} />
</div>
</div>
</div>

View File

@@ -21,6 +21,7 @@ interface HackFormEditProps {
slug: string;
initial: React.ComponentProps<typeof HackEditForm>["initial"];
catalogTags: CatalogTagRow[];
tagsUpdatedAt: Date;
}
export type HackFormProps = HackFormCreateProps | HackFormEditProps;
@@ -35,7 +36,12 @@ export default function HackForm(props: HackFormProps) {
catalogTags={props.catalogTags}
/>;
}
return <HackEditForm slug={props.slug} initial={props.initial} catalogTags={props.catalogTags} />;
return <HackEditForm
slug={props.slug}
initial={props.initial}
catalogTags={props.catalogTags}
tagsUpdatedAt={props.tagsUpdatedAt}
/>;
}

View File

@@ -902,7 +902,7 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} />
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} newTagsCutoff={null} />
</div>
<div className="grid gap-1">

View File

@@ -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<React.SVGProps<SVGSVGElement>> | 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<TagRow[]>(() => catalogTags ?? []);
@@ -140,6 +149,16 @@ export default function TagSelector({ value, onChange, catalogTags }: TagSelecto
setCategoriesPaneFocused(true);
}, []);
const categoriesWithNewTags = React.useMemo(() => {
const categories = new Set<string>();
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<string, TagRow[]>();
@@ -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'
}`}
>
<span className="truncate inline-flex items-center gap-2">{Icon ? <Icon className="h-4 w-4 opacity-80" /> : null}{cat}</span>
<span className="truncate inline-flex items-center gap-2">
{Icon ? <Icon className="h-4 w-4 opacity-80" /> : null}
{cat}
{newTagsCutoff && categoriesWithNewTags.includes(cat) && (
<span className="ml-1 rounded-full bg-black/5 px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-foreground/60 dark:bg-white/5">New</span>
)}
</span>
</div>
);})}
{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'}`}
>
<span className="truncate">{t.name}</span>
{t.created_at && newTagsCutoff && new Date(t.created_at) > newTagsCutoff && (
<span className="ml-auto mr-2 rounded-full bg-black/5 px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-foreground/60 dark:bg-white/5">New</span>
)}
<input type="checkbox" readOnly checked={value.includes(t.name)} className="h-4 w-4 accent-[var(--accent)]" />
</div>
))}

View File

@@ -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<CatalogTagRow[]> {
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();

View File

@@ -4,4 +4,5 @@ export type CatalogTagRow = {
name: string;
category: string | null;
popularity: number;
created_at: string | null;
};

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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 $$;