"use client"; import { searchItems } from "@/lib/search"; import dynamic from "next/dynamic"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { ArrowLeftIcon, BookOpenIcon, ClipboardCopyIcon, ClipboardPasteIcon, CopyIcon, CircleHelpIcon, GripVerticalIcon, HeartPulseIcon, PlusIcon, RefreshCcwIcon, SearchIcon, SparklesIcon, Trash2Icon, XIcon, } from "lucide-react"; import { useEffect, useMemo, useState, type ComponentProps, type ReactNode, type SetStateAction } from "react"; import { toast } from "sonner"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { deleteGuideExtra, saveArtifactSection, saveConstellationSection, saveGuideExtra, listTeamImportGuides, loadTeamImportGuide, saveTeamSection, saveWeaponSection, } from "@/app/admin/actions"; import { AutosaveStatus } from "@/components/admin/autosave-status"; import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog"; import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog"; import { MediaUploader } from "@/components/admin/media-uploader"; import { ScrollableNav } from "@/components/public/scrollable-nav"; import { TeamArtifactIcon } from "@/components/public/team-search"; import { SimpleTooltip } from "@/components/tooltip"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { ColorPicker, ColorPickerEyeDropper, ColorPickerHue, ColorPickerSelection, } from "@/components/ui/color-picker"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Combobox, FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, } from "@/components/ui/combobox"; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { useAutosave } from "@/hooks/use-autosave"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { artifactSectionSchema, constellationSectionSchema, extraSectionSchema, mainStatOptions, substatOptions, talentLevelOptions, teamSectionSchema, weaponSectionSchema, } from "@/lib/guides/schemas"; import { normalizeTargetStat, recommendedStatElement, recommendedStatIconName, recommendedStatOptionConflicts, targetStatDefaults, targetStatIconName, targetStatOptions, statPriorityColorClass, } from "@/lib/guides/stats"; import { elementBackgroundColor, elementIconFilter } from "@/lib/catalog/element"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { isCustomTravelerKey } from "@/lib/catalog/travelers"; import { applyPresetFields, applyTeamFields, cloneTeamImports, findInvalidTeamCatalogReference, findUnsupportedTeamWeapon, limitPresets, parseTeamPresets, parseTeamTransfer, serializeTeamTransfer, teamTransferFileName, teamPresetCookieKey, teamPresetStorageKey, type TeamApplySelection, type TeamMemberApplyField, type TeamPreset, type TeamToolValue, } from "@/lib/guides/team-tools"; import { searchTeams, type TeamSearchTarget, } from "@/lib/guides/team-search"; import { normalizeTalentPriorities, reorderTalentPriorities, type TalentPriority, type TalentRelation, } from "@/lib/guides/talents"; import { finalizePercentInput, normalizePercentInput, } from "@/lib/guides/percent-input"; import { formatGuideValidationIssues } from "@/lib/guides/validation-messages"; import { normalizeExtraRichText } from "@/lib/rich-text"; import { createExtraSheet, type ExtraSheet } from "@/lib/guides/extra-sheet"; import { cn } from "@/lib/utils"; type CatalogItem = { key: string; name: string; aliases?: string[]; imageUrl: string; rarity?: number | null; weaponType?: string | null; element?: string | null; elementIconUrl?: string | null; }; const catalogItemsEqual = (item: CatalogItem, value: CatalogItem) => item.key === value.key; type ExtraCatalogItem = { kind: "character" | "weapon" | "artifact"; key: string; }; const teamMemberApplyOptions = [ ["characterKey", "Character"], ["weaponKey", "Weapon"], ["artifactKey", "Artifact 1"], ["secondArtifactKey", "Artifact 2"], ["constellation", "C"], ["refinement", "R"], ["damage", "Damage"], ] as const satisfies readonly (readonly [TeamMemberApplyField, string])[]; const multipleApplyStorageKey = (guideId: string) => `buzz-sheet:team-multiple-apply:${guideId}`; type Guide = { id: string; characterKey: string; version: number; name: string; }; type WeaponValue = { id: string; weaponKey: string; refinement: string; overallPercent: string | null; personalPercent: string | null; note: string | null; weapon: CatalogItem; }; type WeaponGroupValue = { id: string; name: string | null; note: string | null; rows: WeaponValue[]; }; type ArtifactTierValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string; note: string | null; }>; }; type ArtifactValue = { id: string; name: string | null; note: string | null; tiers: ArtifactTierValue[]; }; type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; recommendedConditions: Array<{ name?: string | null; note: string | null; sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; }>; targets: Array<{ stat: string; value: string }>; targetConditions: Array<{ name?: string | null; note: string | null; rows: Array<{ stat: string; value: string }>; }>; talents: TalentPriority[]; note: string | null; } | null; type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean; }; type ConstellationGroupValue = { id: string; name: string | null; note: string | null; rows: ConstellationValue[]; }; type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null; }; type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null; }; type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; secondArtifactKey: string | null; constellation: number; refinement: number; damage: string; }>; }; type TeamGroupValue = { id: string; name: string | null; note: string | null; teams: TeamValue[]; }; let clientIdCounter = 0; const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C"; const RichTextEditor = dynamic( () => import("@/components/rich-text-editor").then((module) => module.RichTextEditor), { ssr: false, loading: () => , }, ); const ExtraSheetEditor = dynamic( () => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor), { ssr: false, loading: () => , }, ); 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}`; } function ConditionRenameDialog({ open, fallback, value, onOpenChange, onChange, }: { open: boolean; fallback: string; value: string; onOpenChange: (open: boolean) => void; onChange: (value: string) => void; }) { return ( Rename condition Leave blank to use {fallback}. Condition name onChange(event.target.value)} /> ); } 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; } 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; }>; }>; }; } const SELECTED_TOGGLE_CLASS = "aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30"; function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false, showSelectedImage = false, invalid = false, }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean; showSelectedImage?: boolean; invalid?: boolean; }) { const selected = items.find((item) => item.key === value) ?? null; return (
{showSelectedImage && selected ? (
) : null}
onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > ไม่พบรายการ {(item) => (
{item.name}
)}
); } function CatalogSlotPicker({ items, value, onChange, label, placeholder, rarityBackground = false, invalid = false, className, }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; label: string; placeholder: string; rarityBackground?: boolean; invalid?: boolean; className?: string; }) { const selected = items.find((item) => item.key === value) ?? null; return ( onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > svg]:hidden", rarityBackground && selected && rarityGradientClass(selected.rarity), className, )} > {selected ? ( ) : ( ไม่พบรายการ {(item) => (
{item.name}
)}
); } function ArtifactSlotPicker({ items, values, onChange, label, invalid = false, }: { items: CatalogItem[]; values: string[]; onChange: (values: string[]) => void; label: string; invalid?: boolean; }) { const firstKey = values[0]; const secondKey = values[1]; const selected = useMemo( () => [firstKey, secondKey].flatMap((value) => { const item = items.find((candidate) => candidate.key === value); return item ? [item] : []; }), [firstKey, items, secondKey], ); const artifactIconValue = (item: CatalogItem | undefined) => item ? { key: item.key, name: item.name, imageUrl: item.imageUrl, rarity: item.rarity ?? null, } : null; return ( onChange(next.slice(0, 2).map((item) => item.key)) } itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} isItemEqualToValue={catalogItemsEqual} searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > item.name).join(", ")}` : label} aria-invalid={invalid} className="relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&>svg]:hidden" > {selected.length ? ( ) : ( ไม่พบชุด Artifact {(item) => ( = 2 && !selected.some((candidate) => candidate.key === item.key) } >
{item.name}
)}
); } function StatSelect({ label, options, value, onChange, elementIconUrls, priorityColors = false, }: { label: string; options: readonly string[]; value: string[]; onChange: (value: string[]) => void; elementIconUrls: Record; priorityColors?: boolean; }) { return ( {label} ); } function StatIcons({ values, elementIconUrls = {}, }: { values: readonly string[]; elementIconUrls?: Record; }) { const icons = values.flatMap((value) => { const element = recommendedStatElement(value); if (element && elementIconUrls[element]) { return [{ name: element, src: elementIconUrls[element], element }]; } const targetIcon = targetStatIconName(value); if (targetIcon) { return [{ name: targetIcon, src: `/icon/stats/${targetIcon}.webp` }]; } const recommendedIcon = recommendedStatIconName(value); return recommendedIcon ? [{ name: recommendedIcon, src: `/icon/stats/${recommendedIcon}.${recommendedIcon === "elemental-bonus" ? "svg" : "webp"}`, }] : []; }); if (!icons.length) return null; return ( ); } function StatIcon({ name, src, element, }: { name: string; src: string; element?: string; }) { const [hasError, setHasError] = useState(false); if (hasError) { const PlaceholderIcon = name === "healing-bonus" ? HeartPulseIcon : SparklesIcon; return (