feat : update admin ui
This commit is contained in:
+168
-193
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user