mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-04-05 00:54:50 -05:00
Allow hack tags to be reordered
This commit is contained in:
parent
b8cefaa3d3
commit
970491cb85
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())))))
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user