"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 (
);
}
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 ? : null}
{label}
{index === 0 ? Default : null}
);
}
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 (
{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;