mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Label new tags for creators + tag categories migration fix (#64)
Some checks are pending
Deploy Supabase Migrations to Production / migrate (push) Waiting to run
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,4 +4,5 @@ export type CatalogTagRow = {
|
||||
name: string;
|
||||
category: string | null;
|
||||
popularity: number;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
18
supabase/migrations/20260706161539_created_at_for_tags.sql
Normal file
18
supabase/migrations/20260706161539_created_at_for_tags.sql
Normal 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;
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user