feat : update admin ui

This commit is contained in:
2026-10-01 00:53:20 +07:00 Unverified
parent 43ed3aebe9
commit cf23c572aa
4 changed files with 354 additions and 262 deletions
+168 -193
View File
@@ -42,6 +42,11 @@ import {
WeaponSelect,
} from "@/components/admin/ui/catalog-select";
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
import {
StygianBossCard,
StygianBossTab,
type StygianBossDisplay,
} from "@/components/stygian/stygian-boss-display";
import {
AdminDropdown,
ConstellationSelect,
@@ -50,12 +55,6 @@ import {
} from "@/components/admin/ui/option-select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldGroup,
@@ -94,7 +93,7 @@ type Character = CatalogItem & {
elementIconUrl: string | null;
};
type ScheduleSummary = { scheduleId: number; challengeName: string };
type Boss = { level: number; position: number; name: string; imageUrl: string };
type Boss = StygianBossDisplay & { level: number; position: number };
type StoredItem = {
kind: string;
catalogKey: string | null;
@@ -315,7 +314,6 @@ function VisualRecommendation({
weapons,
artifacts,
teamLayout = false,
position,
dragHandle,
}: {
item: StygianRecommendationItemInput;
@@ -325,7 +323,6 @@ function VisualRecommendation({
weapons: CatalogItem[];
artifacts: CatalogItem[];
teamLayout?: boolean;
position?: number;
dragHandle?: ReactNode;
}) {
const character =
@@ -364,17 +361,14 @@ function VisualRecommendation({
if (item.kind === "character") onChange({ ...item, ...patch });
};
return (
<div className="group relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
<div className="relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
{dragHandle ? (
<div className="mb-1 flex h-7 items-center justify-end">
{dragHandle}
</div>
) : null}
{character ? (
<>
{teamLayout ? (
<div className="mb-1 flex h-7 items-center justify-between">
<span className="text-xs text-muted-foreground">
#{(position ?? 0) + 1}
</span>
{dragHandle}
</div>
) : null}
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
<CharacterSelect
presentation="slot"
@@ -582,7 +576,7 @@ function VisualRecommendation({
<Button
variant="destructive"
size="icon-xs"
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
className="absolute top-2 left-2"
aria-label="Remove recommendation"
onClick={onRemove}
>
@@ -619,7 +613,7 @@ function SortableRecommendation({
variant="ghost"
size="icon-xs"
className="touch-none"
aria-label="Drag to reorder character"
aria-label="Drag to reorder recommendation"
{...attributes}
{...listeners}
>
@@ -666,7 +660,7 @@ function VisualRecommendationGrid({
className={cn(
"grid gap-2",
teamLayout
? "grid-cols-[repeat(4,minmax(0,7.5rem))]"
? "grid-cols-4 gap-1 sm:grid-cols-[repeat(4,minmax(0,7.5rem))] sm:gap-2"
: "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]",
)}
>
@@ -675,7 +669,6 @@ function VisualRecommendationGrid({
<VisualRecommendation
key={sortableIds[index]}
item={item}
position={index}
dragHandle={dragHandle}
teamLayout={teamLayout || characterOnly}
characters={characters}
@@ -693,15 +686,10 @@ function VisualRecommendationGrid({
}
/>
);
return teamLayout ? (
<SortableRecommendation
key={sortableIds[index]}
id={sortableIds[index]}
>
return (
<SortableRecommendation key={sortableIds[index]} id={sortableIds[index]}>
{card}
</SortableRecommendation>
) : (
card()
);
})}
{Array.from({ length: emptyCount }, (_, index) =>
@@ -723,7 +711,6 @@ function VisualRecommendationGrid({
)}
</div>
);
if (!teamLayout) return cards;
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const from = sortableIds.indexOf(String(active.id));
@@ -766,25 +753,23 @@ function SortableTeam({
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "relative z-10 opacity-60")}
>
<Card size="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Team {position + 1}</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="touch-none"
aria-label={`Drag to reorder team ${position + 1}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
</div>
{children}
</CardContent>
</Card>
<div className="flex flex-col gap-2 rounded-lg border bg-muted/20 p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Team {position + 1}</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="touch-none"
aria-label={`Drag to reorder team ${position + 1}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
</div>
{children}
</div>
</div>
);
}
@@ -900,50 +885,33 @@ function BossRecommendationEditor({
]);
return (
<Card>
<CardContent className="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
<aside className="self-start">
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30">
<Image
src={boss.imageUrl}
alt={boss.name}
fill
sizes="192px"
className="object-contain p-3"
/>
</div>
<h3 className="mt-3 font-heading font-medium leading-snug">
{boss.name}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Level {level} recommendations
</p>
</aside>
<div className="flex min-w-0 flex-col gap-6">
<FieldSet className="gap-3">
<FieldLegend>Recommended team</FieldLegend>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const from = teamIds.indexOf(String(active.id));
const to = teamIds.indexOf(String(over.id));
if (from >= 0 && to >= 0)
setTeams((current) => arrayMove(current, from, to));
}}
>
<SortableContext
items={teamIds}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{teams.map((team, teamIndex) => (
<SortableTeam
key={teamIds[teamIndex]}
id={teamIds[teamIndex]}
position={teamIndex}
>
<StygianBossCard boss={boss} level={level}>
<FieldSet className="gap-3">
<FieldLegend className="font-heading text-lg font-semibold">
Recommended team
</FieldLegend>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const from = teamIds.indexOf(String(active.id));
const to = teamIds.indexOf(String(over.id));
if (from >= 0 && to >= 0)
setTeams((current) => arrayMove(current, from, to));
}}
>
<SortableContext
items={teamIds}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{teams.map((team, teamIndex) => (
<SortableTeam
key={teamIds[teamIndex]}
id={teamIds[teamIndex]}
position={teamIndex}
>
<VisualRecommendationGrid
items={team}
limit={4}
@@ -975,51 +943,53 @@ function BossRecommendationEditor({
description="This team and all of its character recommendations will be removed."
confirmLabel="Remove team"
onConfirm={() =>
setTeams(teams.filter((_, index) => index !== teamIndex))
setTeams(
teams.filter((_, index) => index !== teamIndex),
)
}
>
<Trash2Icon data-icon="inline-start" />
Remove team
</DeleteConfirmationDialog>
</SortableTeam>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setTeams([...teams, []])}
>
<PlusIcon data-icon="inline-start" />
Add team
</Button>
</FieldSet>
<FieldSet className="gap-3">
<FieldLegend>Recommended unit</FieldLegend>
<VisualRecommendationGrid
items={units}
characters={characters}
weapons={weapons}
artifacts={artifacts}
onChange={setUnits}
/>
</FieldSet>
<div className="flex justify-end">
<AutosaveStatus
state={saveState}
issues={
valid
? []
: ["Add at least 1 character to each team before autosave."]
}
onSave={() => setFailedFingerprint(null)}
/>
</div>
</div>
</CardContent>
</Card>
</SortableTeam>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setTeams([...teams, []])}
>
<PlusIcon data-icon="inline-start" />
Add team
</Button>
</FieldSet>
<FieldSet className="gap-3">
<FieldLegend className="font-heading text-lg font-semibold">
Recommended unit
</FieldLegend>
<VisualRecommendationGrid
items={units}
characters={characters}
weapons={weapons}
artifacts={artifacts}
onChange={setUnits}
/>
</FieldSet>
<div className="flex justify-end">
<AutosaveStatus
state={saveState}
issues={
valid
? []
: ["Add at least 1 character to each team before autosave."]
}
onSave={() => setFailedFingerprint(null)}
/>
</div>
</StygianBossCard>
);
}
@@ -1047,53 +1017,50 @@ export function StygianEditor({
const [pending, startTransition] = useTransition();
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Schedules</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{schedules.map((entry) =>
entry.scheduleId === schedule.scheduleId ? (
<Button key={entry.scheduleId} disabled variant="secondary">
{entry.challengeName}
</Button>
) : (
<Button
key={entry.scheduleId}
variant="outline"
nativeButton={false}
render={
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
}
>
{entry.challengeName}
</Button>
),
<nav className="flex flex-wrap gap-2" aria-label="Stygian versions">
{schedules.map((entry) =>
entry.scheduleId === schedule.scheduleId ? (
<Button key={entry.scheduleId} disabled variant="secondary" size="sm">
{entry.challengeName}
</Button>
) : (
<Button
key={entry.scheduleId}
variant="outline"
size="sm"
nativeButton={false}
render={
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
}
>
{entry.challengeName}
</Button>
),
)}
<Button
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await syncStygianArchiveAction();
if (result.status === "error") toast.error(result.message);
else {
toast.success(
`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`,
);
router.refresh();
}
})
}
>
{pending ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCwIcon data-icon="inline-start" />
)}
<Button
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await syncStygianArchiveAction();
if (result.status === "error") toast.error(result.message);
else {
toast.success(
`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`,
);
router.refresh();
}
})
}
>
{pending ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCwIcon data-icon="inline-start" />
)}
Sync archive
</Button>
</CardContent>
</Card>
Sync archive
</Button>
</nav>
<Tabs
value={String(level)}
@@ -1109,23 +1076,31 @@ export function StygianEditor({
value={String(bossPosition)}
onValueChange={(next) => setBossPosition(Number(next))}
>
<TabsList
variant="line"
className="grid h-10 w-full grid-cols-3 overflow-hidden"
>
{bosses
.filter((boss) => boss.level === entry)
.map((boss) => (
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto group-data-horizontal/tabs:min-h-96" aria-label={`Level ${entry} bosses`}>
{[0, 1, 2].map((position) => {
const boss = bosses.find(
(candidate) =>
candidate.level === entry &&
candidate.position === position,
);
const label = boss?.name ?? `Boss ${position + 1}`;
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
return (
<TabsTrigger
key={boss.position}
value={String(boss.position)}
className="h-full min-w-0 overflow-hidden rounded-none px-2"
key={position}
value={String(position)}
aria-label={`${positionLabel}: ${label}`}
title={label}
className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-2 py-3"
>
<span className="block min-w-0 truncate">
{boss.name}
</span>
<StygianBossTab
boss={boss}
position={position}
priority={entry === 110}
/>
</TabsTrigger>
))}
);
})}
</TabsList>
{bosses
.filter((boss) => boss.level === entry)