531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import Image from "@/components/ui/resilient-image";
|
|
import { CopyIcon, GripVerticalIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
|
import { useState, type ComponentProps } from "react";
|
|
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
|
import { MediaUploader } from "@/components/admin/media-uploader";
|
|
import { ConditionTabIcon } from "@/components/public/condition-switcher";
|
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
|
import { SimpleTooltip } from "@/components/tooltip";
|
|
import type { CatalogTextNode } from "@/lib/catalog/lunaris";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { type TalentPriority } from "@/lib/guides/talents";
|
|
import { type ExtraSheet } from "@/lib/guides/extra-sheet";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
export type CatalogItem = {
|
|
key: string;
|
|
name: string;
|
|
aliases?: string[];
|
|
imageUrl: string;
|
|
rarity?: number | null;
|
|
weaponType?: string | null;
|
|
element?: string | null;
|
|
elementIconUrl?: string | null;
|
|
};
|
|
|
|
export type ExtraCatalogItem = {
|
|
kind: "character" | "weapon" | "artifact";
|
|
key: string;
|
|
};
|
|
|
|
export type Guide = {
|
|
id: string;
|
|
characterKey: string;
|
|
version: number;
|
|
name: string;
|
|
};
|
|
|
|
export type WeaponValue = {
|
|
id: string;
|
|
weaponKey: string;
|
|
refinement: string;
|
|
overallPercent: string | null;
|
|
personalPercent: string | null;
|
|
note: string | null;
|
|
weapon: CatalogItem;
|
|
};
|
|
|
|
export type WeaponGroupValue = {
|
|
id: string;
|
|
name: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
rows: WeaponValue[];
|
|
};
|
|
|
|
export type ArtifactTierValue = {
|
|
id: string;
|
|
name: string;
|
|
rows: Array<{
|
|
firstArtifactKey: string;
|
|
secondArtifactKey: string | null;
|
|
percent: string;
|
|
note: string | null;
|
|
}>;
|
|
};
|
|
|
|
export type ArtifactValue = {
|
|
id: string;
|
|
name: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
tiers: ArtifactTierValue[];
|
|
};
|
|
|
|
export type ArtifactProfile = {
|
|
sands: string[];
|
|
goblet: string[];
|
|
circlet: string[];
|
|
substats: string[];
|
|
recommendedConditions: Array<{
|
|
name?: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
sands: string[];
|
|
goblet: string[];
|
|
circlet: string[];
|
|
substats: string[];
|
|
}>;
|
|
targets: Array<{ stat: string; value: string }>;
|
|
targetConditions: Array<{
|
|
name?: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
rows: Array<{ stat: string; value: string }>;
|
|
}>;
|
|
talents: TalentPriority[];
|
|
note: string | null;
|
|
} | null;
|
|
|
|
export type ConstellationValue = {
|
|
level: number;
|
|
detail: string;
|
|
cumulativePercent: string;
|
|
isGreat: boolean;
|
|
};
|
|
|
|
export type ConstellationGroupValue = {
|
|
id: string;
|
|
name: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
rows: ConstellationValue[];
|
|
};
|
|
|
|
export type CatalogConstellation = {
|
|
level: number;
|
|
name: string;
|
|
description: string;
|
|
descriptionNodes?: CatalogTextNode[];
|
|
iconUrl: string | null;
|
|
};
|
|
|
|
export type CatalogTalent = {
|
|
type: "NA" | "E" | "Q";
|
|
name: string;
|
|
iconUrl: string | null;
|
|
};
|
|
|
|
export type TeamValue = {
|
|
id: string;
|
|
title: string;
|
|
rotationSeconds: string;
|
|
combo: string;
|
|
members: Array<{
|
|
characterKey: string;
|
|
weaponKey: string | null;
|
|
artifactKey: string | null;
|
|
secondArtifactKey: string | null;
|
|
constellation: string;
|
|
refinement: string;
|
|
damage: string;
|
|
}>;
|
|
};
|
|
|
|
export type TeamGroupValue = {
|
|
id: string;
|
|
name: string | null;
|
|
note: string | null;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
teams: TeamValue[];
|
|
};
|
|
|
|
|
|
export let clientIdCounter = 0;
|
|
|
|
|
|
export function createClientId(prefix: string): string {
|
|
const browserCrypto = globalThis.crypto;
|
|
if (typeof browserCrypto?.randomUUID === "function")
|
|
return `${prefix}-${browserCrypto.randomUUID()}`;
|
|
clientIdCounter += 1;
|
|
return `${prefix}-${Date.now()}-${clientIdCounter}`;
|
|
}
|
|
|
|
|
|
export function ConditionRenameDialog({
|
|
open,
|
|
fallback,
|
|
value,
|
|
iconUrl,
|
|
onOpenChange,
|
|
onChange,
|
|
onIconChange,
|
|
}: {
|
|
open: boolean;
|
|
fallback: string;
|
|
value: string;
|
|
iconUrl: string | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
onChange: (value: string) => void;
|
|
onIconChange: (icon: { iconMediaId: string | null; iconUrl: string | null }) => void;
|
|
}) {
|
|
const [uploading, setUploading] = useState(false);
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => { if (!uploading) onOpenChange(next); }}>
|
|
<DialogContent showCloseButton={!uploading}>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit condition</DialogTitle>
|
|
<DialogDescription>
|
|
Leave blank to use {fallback}.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Field>
|
|
<FieldLabel htmlFor="condition-name">Condition name</FieldLabel>
|
|
<Input
|
|
id="condition-name"
|
|
autoFocus
|
|
maxLength={120}
|
|
placeholder={fallback}
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
/>
|
|
</Field>
|
|
<ConditionIconField iconUrl={iconUrl} onChange={onIconChange} onUploadingChange={setUploading} />
|
|
<DialogFooter>
|
|
<Button type="button" disabled={uploading} onClick={() => onOpenChange(false)}>Done</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|
|
export function ConditionIconField({ iconUrl, onChange, onUploadingChange }: {
|
|
iconUrl: string | null;
|
|
onChange: (icon: { iconMediaId: string | null; iconUrl: string | null }) => void;
|
|
onUploadingChange?: (uploading: boolean) => void;
|
|
}) {
|
|
return <div className="flex flex-col gap-3">
|
|
{iconUrl ? <div className="flex items-center gap-3">
|
|
<Image src={iconUrl} alt="" width={40} height={40} className="size-10 object-contain" />
|
|
<Button type="button" variant="outline" size="sm" onClick={() => onChange({ iconMediaId: null, iconUrl: null })}>Remove icon</Button>
|
|
</div> : null}
|
|
<MediaUploader onReady={(asset) => onChange({ iconMediaId: asset.id, iconUrl: asset.url })} onUploadingChange={onUploadingChange} />
|
|
</div>;
|
|
}
|
|
|
|
|
|
export function parseEditorDraft<Draft>(
|
|
value: unknown,
|
|
arrayKeys: string[],
|
|
stringKeys: string[] = [],
|
|
): Draft | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (arrayKeys.some((key) => !Array.isArray(record[key]))) return null;
|
|
if (stringKeys.some((key) => typeof record[key] !== "string")) return null;
|
|
return value as Draft;
|
|
}
|
|
|
|
|
|
export function hasIssuePrefix(
|
|
issues: readonly { path: readonly PropertyKey[] }[],
|
|
...path: (string | number)[]
|
|
): boolean {
|
|
return issues.some((issue) => path.every((part, index) => issue.path[index] === part));
|
|
}
|
|
|
|
|
|
export interface GuideSectionEditorProps {
|
|
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
|
|
extraSectionId?: string;
|
|
guide: Guide;
|
|
weapons: CatalogItem[];
|
|
artifacts: CatalogItem[];
|
|
characters: CatalogItem[];
|
|
catalogConstellations: CatalogConstellation[];
|
|
catalogTalents: CatalogTalent[];
|
|
initial: {
|
|
sectionNote: string | null;
|
|
weaponGroups: WeaponGroupValue[];
|
|
artifactGroups: ArtifactValue[];
|
|
artifactProfile: ArtifactProfile;
|
|
constellationGroups: ConstellationGroupValue[];
|
|
teams: TeamValue[];
|
|
teamGroups: TeamGroupValue[];
|
|
extras: Array<{
|
|
id: string;
|
|
title: string;
|
|
rows: Array<{
|
|
id: string;
|
|
mediaId: string | null;
|
|
heading: string;
|
|
body: string;
|
|
percent: string | null;
|
|
backgroundColor: string | null;
|
|
textColor: string | null;
|
|
sheet: ExtraSheet | null;
|
|
catalogItems: ExtraCatalogItem[];
|
|
highlights: Array<{ text: string; color: string }>;
|
|
imageUrl: string | null;
|
|
imageWidth: number | null;
|
|
imageHeight: number | null;
|
|
}>;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
|
|
export const SELECTED_TOGGLE_CLASS =
|
|
"aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
|
|
|
|
|
|
export type ConditionNavItem = {
|
|
clientId: string;
|
|
name: string;
|
|
iconMediaId: string | null;
|
|
iconUrl: string | null;
|
|
};
|
|
|
|
|
|
export function SortableConditionTab({
|
|
item,
|
|
index,
|
|
active,
|
|
onSelect,
|
|
onRename,
|
|
}: {
|
|
item: ConditionNavItem;
|
|
index: number;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
onRename: () => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: item.clientId });
|
|
const label = item.name.trim() || `Condition ${index + 1}`;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
|
className={cn("flex shrink-0 items-center rounded-md", isDragging && "z-10 opacity-60")}
|
|
>
|
|
{index > 0 ? <Separator orientation="vertical" aria-hidden="true" className="mx-1 h-6" /> : null}
|
|
<TabsTrigger
|
|
value={item.clientId}
|
|
onClick={onSelect}
|
|
onDoubleClick={onRename}
|
|
>
|
|
<ConditionTabIcon iconUrl={item.iconUrl} />
|
|
<span className="text-base">{label}</span>
|
|
{index === 0 ? <Badge variant={active ? "secondary" : "outline"}>Default</Badge> : null}
|
|
</TabsTrigger>
|
|
<SimpleTooltip text={`ลากเพื่อจัดลำดับ ${label}`}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="touch-none rounded-l-none"
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVerticalIcon />
|
|
<span className="sr-only">ลากเพื่อจัดลำดับ {label}</span>
|
|
</Button>
|
|
</SimpleTooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
export function SortableConditionSwitcher({
|
|
ariaLabel,
|
|
items,
|
|
activeIndex,
|
|
onActiveIndexChange,
|
|
onReorder,
|
|
onRename,
|
|
}: {
|
|
ariaLabel: string;
|
|
items: ConditionNavItem[];
|
|
activeIndex: number;
|
|
onActiveIndexChange: (index: number) => void;
|
|
onReorder: (from: number, to: number) => void;
|
|
onRename: (index: number) => void;
|
|
}) {
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
);
|
|
const activeId = items[activeIndex]?.clientId;
|
|
|
|
return (
|
|
<ScrollableNav ariaLabel={ariaLabel} className="min-w-0 flex-1">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={({ active, over }) => {
|
|
if (!over || active.id === over.id) return;
|
|
const from = items.findIndex((item) => item.clientId === active.id);
|
|
const to = items.findIndex((item) => item.clientId === over.id);
|
|
if (from !== -1 && to !== -1) onReorder(from, to);
|
|
}}
|
|
>
|
|
<SortableContext items={items.map((item) => item.clientId)} strategy={rectSortingStrategy}>
|
|
<Tabs value={activeId} onValueChange={(value) => {
|
|
const index = items.findIndex((item) => item.clientId === value);
|
|
if (index !== -1) onActiveIndexChange(index);
|
|
}}>
|
|
<TabsList aria-label={ariaLabel} className="shrink-0 gap-1">
|
|
{items.map((item, index) => (
|
|
<SortableConditionTab
|
|
key={item.clientId}
|
|
item={item}
|
|
index={index}
|
|
active={item.clientId === activeId}
|
|
onSelect={() => onActiveIndexChange(index)}
|
|
onRename={() => onRename(index)}
|
|
/>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</ScrollableNav>
|
|
);
|
|
}
|
|
|
|
|
|
export function ConditionControls({
|
|
onAdd,
|
|
onDuplicate,
|
|
onDelete,
|
|
...switcherProps
|
|
}: ComponentProps<typeof SortableConditionSwitcher> & {
|
|
onAdd: (details: { name: string; iconMediaId: string | null; iconUrl: string | null }) => void;
|
|
onDuplicate?: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const { items, activeIndex } = switcherProps;
|
|
const atLimit = items.length >= 20;
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [newName, setNewName] = useState("");
|
|
const [newIcon, setNewIcon] = useState<{ iconMediaId: string | null; iconUrl: string | null }>({ iconMediaId: null, iconUrl: null });
|
|
const [uploading, setUploading] = useState(false);
|
|
const closeCreate = () => {
|
|
if (uploading) return;
|
|
setCreateOpen(false);
|
|
setNewName("");
|
|
setNewIcon({ iconMediaId: null, iconUrl: null });
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
<SortableConditionSwitcher {...switcherProps} />
|
|
<SimpleTooltip text="เพิ่ม Condition">
|
|
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={atLimit} onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon />
|
|
</Button>
|
|
</SimpleTooltip>
|
|
<Dialog open={createOpen} onOpenChange={(open) => { if (open) setCreateOpen(true); else closeCreate(); }}>
|
|
<DialogContent showCloseButton={!uploading}>
|
|
<DialogHeader>
|
|
<DialogTitle>Create condition</DialogTitle>
|
|
<DialogDescription>Enter a name and optionally upload an icon.</DialogDescription>
|
|
</DialogHeader>
|
|
<Field>
|
|
<FieldLabel htmlFor="new-condition-name">Condition name</FieldLabel>
|
|
<Input id="new-condition-name" autoFocus maxLength={120} value={newName} onChange={(event) => setNewName(event.target.value)} />
|
|
</Field>
|
|
<ConditionIconField iconUrl={newIcon.iconUrl} onChange={setNewIcon} onUploadingChange={setUploading} />
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" disabled={uploading} onClick={closeCreate}>Cancel</Button>
|
|
<Button type="button" disabled={!newName.trim() || uploading} onClick={() => {
|
|
onAdd({ name: newName.trim(), ...newIcon });
|
|
closeCreate();
|
|
}}>Create</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<SimpleTooltip text="Edit Condition">
|
|
<Button type="button" size="icon" variant="outline" aria-label="Edit Condition" disabled={!items[activeIndex]} onClick={() => switcherProps.onRename(activeIndex)}>
|
|
<PencilIcon />
|
|
</Button>
|
|
</SimpleTooltip>
|
|
{onDuplicate ? (
|
|
<SimpleTooltip text="Duplicate Condition">
|
|
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={atLimit || !items[activeIndex]} onClick={onDuplicate}>
|
|
<CopyIcon />
|
|
</Button>
|
|
</SimpleTooltip>
|
|
) : null}
|
|
{items.length > 1 ? (
|
|
<DeleteConfirmationDialog
|
|
trigger={<Button type="button" size="icon" variant="outline" aria-label="ลบ Condition นี้" />}
|
|
triggerTooltip="ลบ Condition นี้"
|
|
type="Condition"
|
|
description="ข้อมูลทั้งหมดใน Condition นี้จะถูกลบออก"
|
|
onConfirm={onDelete}
|
|
>
|
|
<Trash2Icon />
|
|
<span className="sr-only">ลบ Condition นี้</span>
|
|
</DeleteConfirmationDialog>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
export function reorderConditions<T extends ConditionNavItem>(
|
|
items: T[],
|
|
activeIndex: number,
|
|
from: number,
|
|
to: number,
|
|
) {
|
|
const activeId = items[activeIndex]?.clientId;
|
|
const reordered = arrayMove(items, from, to);
|
|
return {
|
|
items: reordered,
|
|
activeIndex: Math.max(0, reordered.findIndex((item) => item.clientId === activeId)),
|
|
};
|
|
}
|
|
|
|
|
|
export type EditorBase = GuideSectionEditorProps;
|