Files
buzz/app/(ui)/admin/tl/layout/client.tsx
T
dmgnr 8c14999c59
Build and Push Image / build-backend (push) Successful in 6m13s
Build and Push Image / build-frontend (push) Successful in 7m33s
Build and Push Image / push (push) Successful in 1m16s
feat(admin): tierlist editor
2026-08-06 03:00:13 +07:00

345 lines
10 KiB
TypeScript

"use client";
import { GripVertical, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ComponentProps } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { reorderFlatItems } from "../components/order";
import { SortableGrid, SortableGridItem } from "../components/sortable-grid";
import { reorderColumns, reorderTiers } from "./actions";
type Tier = typeof tierlistTiers.$inferSelect;
type Column = typeof tierlistColumns.$inferSelect;
export function LayoutEditor({
tiers: initialTiers,
columns: initialColumns,
}: {
tiers: Tier[];
columns: Column[];
}) {
const [tiers, setTiers] = useState(initialTiers);
const [columns, setColumns] = useState(initialColumns);
const [pending, startTransition] = useTransition();
function commitReorder<T extends { id: string }>(
items: T[],
setItems: (items: T[]) => void,
activeId: string,
overId: string,
persist: (args: { activeId: string; ids: string[] }) => Promise<void>,
label: string,
) {
const next = reorderFlatItems(items, activeId, overId);
if (next === items) return;
const previous = items;
setItems(next);
startTransition(() => {
persist({ activeId, ids: next.map((item) => item.id) }).catch((error) => {
console.error(error);
setItems(previous);
toast.error(
error instanceof Error
? error.message
: `Failed to reorder ${label}.`,
);
});
});
}
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<div
className="grid min-w-fit *:border *:border-white/10"
style={{
gridTemplateColumns: `minmax(180px, 240px) repeat(${Math.max(columns.length, 1)}, minmax(180px, 1fr)) 72px`,
gridTemplateRows: `minmax(88px, auto) repeat(${Math.max(tiers.length, 1)}, minmax(108px, auto)) 72px`,
}}
>
<div className="bg-[#0005]" />
<SortableGrid
items={columns.map((column) => column.id)}
onReorder={(activeId, overId) =>
commitReorder(
columns,
setColumns,
activeId,
overId,
reorderColumns,
"columns",
)
}
disabled={pending}
overlay={(id) => {
const column = columns.find((entry) => entry.id === id);
return column ? (
<LayoutNode
item={column}
href={`/admin/tl/layout/column/${column.id}/edit`}
orientation="column"
overlay
/>
) : null;
}}
>
{columns.length ? (
columns.map((column) => (
<SortableGridItem
key={column.id}
id={column.id}
disabled={pending}
>
{({
attributes,
listeners,
setNodeRef,
style,
isDragging,
}) => (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "z-10")}
>
<LayoutNode
item={column}
href={`/admin/tl/layout/column/${column.id}/edit`}
orientation="column"
dragHandle={{ ...attributes, ...listeners }}
/>
</div>
)}
</SortableGridItem>
))
) : (
<div className="flex items-center justify-center bg-[#0005] p-3 text-sm text-muted-foreground">
Add your first column
</div>
)}
</SortableGrid>
<AddSlot href="/admin/tl/layout/column/create" label="Add column" />
{tiers.length ? (
<SortableGrid
items={tiers.map((tier) => tier.id)}
onReorder={(activeId, overId) =>
commitReorder(
tiers,
setTiers,
activeId,
overId,
reorderTiers,
"tiers",
)
}
disabled={pending}
overlay={(id) => {
const tier = tiers.find((entry) => entry.id === id);
return tier ? (
<LayoutNode
item={tier}
href={`/admin/tl/layout/tier/${tier.id}/edit`}
orientation="tier"
badges={tier.badges}
overlay
/>
) : null;
}}
>
{tiers.map((tier) => (
<TierRow
key={tier.id}
tier={tier}
columnIds={
columns.length
? columns.map((column) => column.id)
: ["empty"]
}
pending={pending}
/>
))}
</SortableGrid>
) : (
<>
<div className="flex items-center justify-center bg-[#0005] p-3 text-sm text-muted-foreground">
Add your first tier
</div>
{(columns.length
? columns.map((column) => column.id)
: ["empty"]
).map((id) => (
<BlankCell key={`empty-tier-cell-${id}`} />
))}
</>
)}
<AddSlot href="/admin/tl/layout/tier/create" label="Add tier" />
{(columns.length
? columns.map((column) => column.id)
: ["empty"]
).map((id) => (
<ThinCell key={`footer-${id}`} />
))}
<ThinCell />
</div>
</div>
</div>
);
}
function TierRow({
tier,
columnIds,
pending,
}: {
tier: Tier;
columnIds: string[];
pending: boolean;
}) {
return (
<>
<SortableGridItem id={tier.id} disabled={pending}>
{({ attributes, listeners, setNodeRef, style, isDragging }) => (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "z-10")}
>
<LayoutNode
item={tier}
href={`/admin/tl/layout/tier/${tier.id}/edit`}
orientation="tier"
badges={tier.badges}
dragHandle={{ ...attributes, ...listeners }}
/>
</div>
)}
</SortableGridItem>
{columnIds.map((columnId) => (
<BlankCell key={`${tier.id}-${columnId}`} />
))}
<BlankCell />
</>
);
}
function BlankCell() {
return <div className="min-h-27 bg-[#0003]" />;
}
function ThinCell() {
return <div className="min-h-18 bg-[#0003]" />;
}
function AddSlot({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="group/add flex items-center justify-center bg-[#0004] text-muted-foreground transition-colors hover:bg-[#0007] hover:text-foreground"
>
<SimpleTooltip text={label}>
<PlusIcon className="size-8 transition-transform group-hover/add:scale-110" />
</SimpleTooltip>
</Link>
);
}
function LayoutNode({
item,
href,
dragHandle,
orientation,
badges,
overlay = false,
}: {
item: { id: string; name: string; image: string | null };
href?: string;
dragHandle?: ComponentProps<"button">;
orientation: "tier" | "column";
badges?: string[] | null;
overlay?: boolean;
}) {
return (
<div
className={cn(
"group/layout relative flex h-full min-h-22 items-center gap-3 bg-[#0005] p-3",
overlay && "shadow-lg",
orientation === "column" && "justify-center text-center",
)}
>
<button
type="button"
className="absolute top-2 left-2 z-10 cursor-grab rounded-sm border bg-card/90 p-0.5 text-muted-foreground active:cursor-grabbing"
{...dragHandle}
>
<GripVertical className="size-3" />
</button>
{href && !overlay && (
<div className="absolute top-2 right-2 opacity-0 transition-opacity group-hover/layout:opacity-100">
<SimpleTooltip text="Edit">
<Button
variant="outline"
size="icon"
asChild
className="size-7 bg-card!"
>
<Link href={href}>
<PencilLine className="size-3" />
</Link>
</Button>
</SimpleTooltip>
</div>
)}
<div
className={cn(
"flex size-16 shrink-0 items-center justify-center rounded border bg-[#2228]",
orientation === "column" && "mx-auto mt-3",
)}
>
{item.image ? (
<Image
src={`/cdn/${item.image}`}
alt={item.name}
width={64}
height={64}
className="max-h-16 w-auto object-contain"
/>
) : (
<span className="text-xl font-bold">{item.name[0] ?? "?"}</span>
)}
</div>
<div className={cn("min-w-0", orientation === "column" && "w-full")}>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="truncate text-lg font-semibold">{item.name}</div>
<Kbd>{item.id}</Kbd>
</div>
{orientation === "tier" && (
<div className="mt-1 flex flex-wrap gap-1">
{badges?.length ? (
<KbdGroup className="flex-wrap gap-1">
{badges.map((badge) => (
<Kbd key={badge}>{badge}</Kbd>
))}
</KbdGroup>
) : (
<span className="text-sm text-muted-foreground">No badges</span>
)}
</div>
)}
</div>
</div>
);
}