Allow hack tags to be reordered

This commit is contained in:
Jared Schoeny 2025-11-28 20:24:58 -10:00
parent b8cefaa3d3
commit 970491cb85
10 changed files with 293 additions and 39 deletions

View File

@ -17,6 +17,7 @@ import serialize from "serialize-javascript";
import { headers } from "next/headers";
import { MenuItem } from "@headlessui/react";
import { FaCircleCheck } from "react-icons/fa6";
import { sortOrderedTags } from "@/utils/format";
interface HackDetailProps {
params: Promise<{ slug: string }>;
@ -133,9 +134,16 @@ export default async function HackDetail({ params }: HackDetailProps) {
const { data: tagRows } = await supabase
.from("hack_tags")
.select("tags(name)")
.select("order,tags(name)")
.eq("hack_slug", slug);
const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[];
const tags = sortOrderedTags(
(tagRows || [])
.map((r) => ({
name: r.tags.name,
order: r.order,
}))
).map((t) => t.name);
const { data: profile } = await supabase
.from("profiles")

View File

@ -54,14 +54,19 @@ export async function updateHack(args: {
}
if (args.tags) {
// Resolve desired tag IDs from names and compute diff against current links
// Resolve desired tag IDs from names and upsert links with explicit ordering
const { data: existingTags, error: tagErr } = await supabase
.from("tags")
.select("id, name")
.in("name", args.tags);
if (tagErr) return { ok: false, error: tagErr.message } as const;
const desiredIds = Array.from(new Set((existingTags || []).map((t) => t.id)));
const byName = new Map((existingTags || []).map((t: any) => [t.name as string, t.id as number]));
// Desired IDs in the exact order provided by the caller
const desiredIds = args.tags
.map((name) => byName.get(name))
.filter((id): id is number => typeof id === "number");
const { data: currentLinks, error: curErr } = await supabase
.from("hack_tags")
@ -72,9 +77,8 @@ export async function updateHack(args: {
const currentIds = new Set((currentLinks || []).map((r: any) => r.tag_id as number));
const desiredSet = new Set(desiredIds);
const toAdd = desiredIds.filter((id) => !currentIds.has(id));
// Remove links for tags that are no longer present
const toRemove = Array.from(currentIds).filter((id) => !desiredSet.has(id));
if (toRemove.length > 0) {
const { error: delErr } = await supabase
.from("hack_tags")
@ -84,10 +88,18 @@ export async function updateHack(args: {
if (delErr) return { ok: false, error: delErr.message } as const;
}
if (toAdd.length > 0) {
const rows = toAdd.map((id) => ({ hack_slug: args.slug, tag_id: id }));
const { error: insErr } = await supabase.from("hack_tags").insert(rows);
if (insErr) return { ok: false, error: insErr.message } as const;
// Upsert links for all desired tags with the correct order
if (desiredIds.length > 0) {
const rows: TablesInsert<"hack_tags">[] = desiredIds.map((id, index) => ({
hack_slug: args.slug,
tag_id: id,
order: index + 1,
}));
const { error: upErr } = await supabase
.from("hack_tags")
.upsert(rows, { onConflict: "hack_slug,tag_id" });
if (upErr) return { ok: false, error: upErr.message } as const;
}
}

View File

@ -4,6 +4,7 @@ import { FaArrowRightLong } from "react-icons/fa6";
import { createClient } from "@/utils/supabase/server";
import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import { sortOrderedTags } from "@/utils/format";
export const metadata: Metadata = {
alternates: {
@ -61,13 +62,16 @@ export default async function Home() {
// Fetch tags
const { data: tagRows } = await supabase
.from("hack_tags")
.select("hack_slug,tags(name,category)")
.select("hack_slug,order,tags(name,category)")
.in("hack_slug", slugs);
const tagsBySlug = new Map<string, string[]>();
const tagsBySlug = new Map<string, { name: string; order: number }[]>();
(tagRows || []).forEach((r: any) => {
if (!r.tags?.name) return;
const arr = tagsBySlug.get(r.hack_slug) || [];
arr.push(r.tags.name);
arr.push({
name: r.tags.name,
order: r.order,
});
tagsBySlug.set(r.hack_slug, arr);
});
@ -103,7 +107,7 @@ export default async function Home() {
title: r.title,
author: usernameById.get(r.created_by as string) || "Unknown",
covers: coversBySlug.get(r.slug) || [],
tags: tagsBySlug.get(r.slug) || [],
tags: sortOrderedTags(tagsBySlug.get(r.slug) || []),
downloads: r.downloads,
baseRomId: r.base_rom,
version: mappedVersions.get(r.slug) || "Pre-release",

View File

@ -91,7 +91,7 @@ export async function prepareSubmission(formData: FormData) {
.in("name", tags);
if (tagErr) return { ok: false, error: tagErr.message } as const;
if (existingTags && existingTags.length > 0) {
const hackTags = existingTags.map((t) => ({ hack_slug: slug, tag_id: t.id }));
const hackTags = existingTags.map((t, i) => ({ hack_slug: slug, tag_id: t.id, order: i + 1 }));
const { error: htErr } = await supabase.from("hack_tags").insert(hackTags);
if (htErr) return { ok: false, error: htErr.message } as const;
}

View File

@ -11,6 +11,7 @@ import { MdTune } from "react-icons/md";
import { BsSdCardFill } from "react-icons/bs";
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { sortOrderedTags, OrderedTag } from "@/utils/format";
export default function DiscoverBrowser() {
@ -88,13 +89,15 @@ export default function DiscoverBrowser() {
}
const { data: tagRows } = await supabase
.from("hack_tags")
.select("hack_slug,tags(name,category)")
.select("hack_slug,order,tags(name,category)")
.in("hack_slug", slugs);
const tagsBySlug = new Map<string, string[]>();
(tagRows || []).forEach((r: any) => {
if (!r.tags?.name) return;
const tagsBySlug = new Map<string, OrderedTag[]>();
(tagRows || []).forEach((r) => {
const arr = tagsBySlug.get(r.hack_slug) || [];
arr.push(r.tags.name);
arr.push({
name: r.tags.name,
order: r.order,
});
tagsBySlug.set(r.hack_slug, arr);
});
@ -126,7 +129,7 @@ export default function DiscoverBrowser() {
title: r.title,
author: usernameById.get(r.created_by as string) || "Unknown",
covers: coversBySlug.get(r.slug) || [],
tags: tagsBySlug.get(r.slug) || [],
tags: sortOrderedTags(tagsBySlug.get(r.slug) || []),
downloads: r.downloads,
baseRomId: r.base_rom,
version: mappedVersions.get(r.slug) || "Pre-release",

View File

@ -20,7 +20,7 @@ import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb";
import { slugify } from "@/utils/format";
import { slugify, sortOrderedTags } from "@/utils/format";
function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; index: number; url: string; filename: string; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
@ -498,7 +498,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
baseRomId: baseRom,
downloads: 0,
version: version || "v0.0.0",
tags,
tags: sortOrderedTags(tags.map((name, index) => ({ name, order: index + 1 }))),
...(boxArt ? { boxArt } : {}),
socialLinks:
discord || twitter || pokecommunity

View File

@ -3,7 +3,7 @@
import PixelImage from "./PixelImage";
import Link from "next/link";
import { formatCompactNumber } from "@/utils/format";
import { formatCompactNumber, OrderedTag } from "@/utils/format";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { baseRoms } from "@/data/baseRoms";
import { useEffect, useRef, useState } from "react";
@ -17,7 +17,7 @@ type CardHack = {
title: string;
author: string;
covers: string[];
tags: string[];
tags: OrderedTag[];
downloads: number;
baseRomId?: string;
version: string;
@ -123,10 +123,10 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
<div className="absolute left-3 top-3 z-10 flex gap-2">
{hack.tags.slice(0, 2).map((t) => (
<span
key={t}
key={t.name}
className="rounded-full px-2 py-0.5 text-xs ring-1 ring-foreground/20 dark:ring-foreground/30 bg-background/70 text-foreground/90 backdrop-blur-md"
>
{t}
{t.name}
</span>
))}
<span

View File

@ -5,6 +5,19 @@ import { createClient } from "@/utils/supabase/client";
import { MdTune } from "react-icons/md";
import { CATEGORY_ICONS, getCategoryIcon } from "@/components/Icons/tagCategories";
import { FaTimes } from "react-icons/fa";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverEvent,
DragEndEvent,
DragCancelEvent,
DragOverlay,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
import { RxDragHandleDots2 } from "react-icons/rx";
type TagRow = {
id: number;
@ -18,6 +31,89 @@ export interface TagSelectorProps {
onChange: (next: string[]) => void;
}
type CategoryIconType = React.ComponentType<React.SVGProps<SVGSVGElement>> | null;
function SortableSelectedTag({
id,
index,
name,
categoryIcon: Icon,
onRemove,
isPrimary,
isGhost,
insertSide,
}: {
id: string;
index: number;
name: string;
categoryIcon: CategoryIconType;
onRemove: () => void;
isPrimary: boolean;
isGhost: boolean;
insertSide: "left" | "right" | null;
}) {
const {
attributes,
listeners,
setNodeRef,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
opacity: isGhost ? 0.5 : undefined,
};
return (
<span
ref={setNodeRef}
style={style}
className={[
"relative inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] sm:text-xs ring-1",
isPrimary
? "bg-[var(--accent-soft,rgba(16,185,129,0.08))] ring-[var(--accent-border,rgba(16,185,129,0.4))]"
: "bg-[var(--surface-2)] ring-[var(--border)]",
isDragging && !isGhost ? "opacity-80 shadow-lg shadow-black/20 dark:shadow-black/40" : "",
]
.filter(Boolean)
.join(" ")}
>
{insertSide === "left" && (
<div className="pointer-events-none absolute -left-1 top-1 bottom-1 w-px bg-[var(--accent,#16a34a)]" />
)}
{insertSide === "right" && (
<div className="pointer-events-none absolute -right-1 top-1 bottom-1 w-px bg-[var(--accent,#16a34a)]" />
)}
<button
type="button"
className="mr-0.5 inline-flex h-5 w-5 sm:h-4 sm:w-4 items-center justify-center rounded-full text-foreground/40 hover:text-foreground/80 cursor-grab active:cursor-grabbing touch-none"
aria-label={`Reorder tag ${name}`}
{...attributes}
{...listeners}
>
<RxDragHandleDots2 className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
</button>
{Icon ? <Icon className="h-3.5 w-3.5 opacity-80" /> : null}
<span className="truncate max-w-[9.5rem] sm:max-w-[12rem]">{name}</span>
{isPrimary && (
<span className="ml-0.5 rounded-full bg-black/5 px-1.5 py-0.5 text-[9px] sm:text-[8px] uppercase tracking-wide text-foreground/60 dark:bg-white/5">
{index === 0 ? "First" : "Second"}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-1 inline-flex h-5 w-5 sm:h-4 sm:w-4 items-center justify-center rounded-full text-foreground/70 hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10"
aria-label={`Remove tag ${name}`}
>
<FaTimes className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
</button>
</span>
);
}
export default function TagSelector({ value, onChange }: TagSelectorProps) {
const supabase = createClient();
const [query, setQuery] = React.useState("");
@ -31,6 +127,14 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) {
const tagItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [activeTagIndex, setActiveTagIndex] = React.useState<number | null>(null);
const [categoriesPaneFocused, setCategoriesPaneFocused] = React.useState(false);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [overId, setOverId] = React.useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const setActiveCategory = React.useCallback((cat: string | "advanced" | null) => {
_setActiveCategory(cat);
@ -125,25 +229,123 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) {
}
}, [activeTagIndex]);
const selectedTagIds = React.useMemo(
() => value.map((t, i) => `${t}__${i}`),
[value],
);
function toggleTag(name: string) {
onChange(value.includes(name) ? value.filter((v) => v !== name) : [...value, name]);
}
const handleDragStart = (event: DragStartEvent) => {
const id = event.active.id as string;
setActiveId(id);
setOverId(id);
};
const handleDragOver = (event: DragOverEvent) => {
if (event.over) {
setOverId(event.over.id as string);
}
};
const handleSelectedDragEnd = (event: DragEndEvent | DragCancelEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
setActiveId(null);
setOverId(null);
return;
}
const oldIndex = selectedTagIds.indexOf(active.id as string);
const newIndex = selectedTagIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const next = arrayMove(value, oldIndex, newIndex);
onChange(next);
setActiveId(null);
setOverId(null);
};
const handleDragCancel = () => {
setActiveId(null);
setOverId(null);
};
return (
<div className="grid gap-2">
{/* Selected tag pills */}
<div className="flex max-h-24 flex-wrap gap-2 overflow-auto pr-1">
{value.length > 0 ? value.map((t) => {
const cat = grouped.categories.find((c) => (grouped.byCat.get(c) || []).some((r) => r.name === t)) || (grouped.advanced.some((r) => r.name === t) ? "Advanced" : undefined);
const Icon = getCategoryIcon(cat === "Advanced" ? null : cat);
return (
<span key={t} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
{Icon ? <Icon className="h-3.5 w-3.5 opacity-80" /> : null}
{t}
<button type="button" onClick={() => toggleTag(t)} className="ml-1 text-foreground/70 hover:text-foreground hover:cursor-pointer"><FaTimes className="h-3 w-3" /></button>
</span>
);
}) : <div className="px-2 py-0.5 text-sm text-foreground/60">No tags selected</div>}
<div className="text-[11px] text-foreground/60">
The first two tags appear as badges on your hack card preview.
</div>
<div className="flex max-h-24 flex-wrap gap-2 overflow-auto p-1">
{value.length > 0 ? (
<DndContext
id="selected-tags"
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleSelectedDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={selectedTagIds} strategy={rectSortingStrategy}>
{value.map((t, index) => {
const cat =
grouped.categories.find((c) => (grouped.byCat.get(c) || []).some((r) => r.name === t)) ||
(grouped.advanced.some((r) => r.name === t) ? "Advanced" : undefined);
const Icon = (getCategoryIcon(cat === "Advanced" ? null : cat) ?? null) as CategoryIconType;
const id = selectedTagIds[index];
const isPrimary = index === 0 || index === 1;
const isGhost = activeId === id;
const overIndex = overId ? selectedTagIds.indexOf(overId) : -1;
const activeIndex = activeId ? selectedTagIds.indexOf(activeId) : -1;
let insertSide: "left" | "right" | null = null;
if (activeId && overIndex === index && activeIndex !== index && activeIndex !== -1) {
insertSide = overIndex > activeIndex ? "right" : "left";
}
return (
<React.Fragment key={id}>
<SortableSelectedTag
id={id}
index={index}
name={t}
categoryIcon={Icon}
onRemove={() => toggleTag(t)}
isPrimary={isPrimary}
isGhost={isGhost}
insertSide={insertSide}
/>
</React.Fragment>
);
})}
</SortableContext>
<DragOverlay>
{activeId ? (() => {
const activeIndex = selectedTagIds.indexOf(activeId);
if (activeIndex === -1) return null;
const t = value[activeIndex];
const cat =
grouped.categories.find((c) => (grouped.byCat.get(c) || []).some((r) => r.name === t)) ||
(grouped.advanced.some((r) => r.name === t) ? "Advanced" : undefined);
const Icon = (getCategoryIcon(cat === "Advanced" ? null : cat) ?? null) as CategoryIconType;
const isPrimary = activeIndex === 0 || activeIndex === 1;
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)] shadow-lg shadow-black/30 dark:shadow-black/60">
{Icon ? <Icon className="h-3.5 w-3.5 opacity-80" /> : null}
<span className="truncate max-w-[9rem] sm:max-w-[12rem]">{t}</span>
{isPrimary && (
<span className="ml-0.5 rounded-full bg-black/5 px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-foreground/60 dark:bg-white/5">
{activeIndex === 0 ? "First" : "Second"}
</span>
)}
</span>
);
})() : null}
</DragOverlay>
</DndContext>
) : (
<div className="px-2 py-0.5 text-sm text-foreground/60">No tags selected</div>
)}
</div>
{/* Persistent selector */}

View File

@ -3,6 +3,21 @@ export function formatCompactNumber(value: number): string {
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
}
export type OrderedTag = { name: string; order: number };
export function sortOrderedTags<T extends OrderedTag>(rows: T[]): T[] {
return [...rows]
.filter((t) => !!t.name)
.sort((a, b) => {
const aHasOrder = a.order > 0;
const bHasOrder = b.order > 0;
if (aHasOrder && !bHasOrder) return -1;
if (!aHasOrder && bHasOrder) return 1;
if (aHasOrder && bHasOrder && a.order !== b.order) return a.order - b.order;
return 0; // fallback to original array order
});
}
export function slugify(text: string) {
return text
.normalize("NFD")

View File

@ -0,0 +1,10 @@
create policy "Owners can update tags on own hacks."
on "public"."hack_tags"
as PERMISSIVE
for UPDATE
to public
using (
(is_admin() OR (EXISTS ( SELECT 1
FROM hacks h
WHERE ((h.slug = hack_tags.hack_slug) AND (h.created_by = auth.uid())))))
);