Files
gunshiz 9580407a90
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s
perf : cache
2026-10-08 23:21:34 +07:00

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;