"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 ( { if (!uploading) onOpenChange(next); }}> Edit condition Leave blank to use {fallback}. Condition name onChange(event.target.value)} /> ); } export function ConditionIconField({ iconUrl, onChange, onUploadingChange }: { iconUrl: string | null; onChange: (icon: { iconMediaId: string | null; iconUrl: string | null }) => void; onUploadingChange?: (uploading: boolean) => void; }) { return
{iconUrl ?
: null} onChange({ iconMediaId: asset.id, iconUrl: asset.url })} onUploadingChange={onUploadingChange} />
; } export function parseEditorDraft( value: unknown, arrayKeys: string[], stringKeys: string[] = [], ): Draft | null { if (!value || typeof value !== "object") return null; const record = value as Record; 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 (
{index > 0 ?
); } 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 ( { 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); }} > item.clientId)} strategy={rectSortingStrategy}> { const index = items.findIndex((item) => item.clientId === value); if (index !== -1) onActiveIndexChange(index); }}> {items.map((item, index) => ( onActiveIndexChange(index)} onRename={() => onRename(index)} /> ))} ); } export function ConditionControls({ onAdd, onDuplicate, onDelete, ...switcherProps }: ComponentProps & { 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 (
{ if (open) setCreateOpen(true); else closeCreate(); }}> Create condition Enter a name and optionally upload an icon. Condition name setNewName(event.target.value)} /> {onDuplicate ? ( ) : null} {items.length > 1 ? ( } triggerTooltip="ลบ Condition นี้" type="Condition" description="ข้อมูลทั้งหมดใน Condition นี้จะถูกลบออก" onConfirm={onDelete} > ลบ Condition นี้ ) : null}
); } export function reorderConditions( 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;