"use client" import { useState, useEffect, useRef, useCallback } from "react" import { Plus, X, ChevronDown, ChevronUp, GripVertical, Copy, FileInput, } from "lucide-react" import { motion, AnimatePresence, Reorder, useDragControls } from "motion/react" export interface SettingItem { title: string value: string | number | boolean } export interface SettingCategory { category: string settings: SettingItem[] } const DEFAULT_CATEGORIES: SettingCategory[] = [ { category: "Graphics", settings: [ { title: "Texture Quality", value: "High" }, { title: "Shadow Quality", value: "Medium" }, { title: "Anti-Aliasing", value: "TAA" }, { title: "Effects Quality", value: "High" }, ], }, { category: "Display", settings: [ { title: "Resolution", value: "1280x800" }, { title: "Refresh Rate", value: 60 }, { title: "VSync", value: false }, { title: "Fullscreen", value: true }, ], }, { category: "Audio", settings: [ { title: "Master Volume", value: 80 }, { title: "SFX Volume", value: 70 }, ], }, ] const typeOptions = [ { value: "text", label: "Abc" }, { value: "number", label: "123" }, { value: "boolean", label: "\u2713/\u2717" }, ] as const interface SettingsEditorProps { value: SettingCategory[] onChange: (categories: SettingCategory[]) => void className?: string } function getTypeMeta(value: string | number | boolean) { if (typeof value === "boolean") { return { label: "\u2713/\u2717", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", title: "Toggle", next: "text" as const, } } if (typeof value === "number") { return { label: "123", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", title: "Number", next: "boolean" as const, } } return { label: "Abc", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", title: "Text", next: "number" as const, } } function convertValue( current: string | number | boolean, targetType: "text" | "number" | "boolean", ): string | number | boolean { if (targetType === "boolean") { if (typeof current === "boolean") return current if (typeof current === "number") return current !== 0 return current.toLowerCase() === "true" } if (targetType === "number") { if (typeof current === "number") return current if (typeof current === "boolean") return current ? 1 : 0 const parsed = parseInt(current, 10) return isNaN(parsed) ? 0 : parsed } // text if (typeof current === "string") return current return String(current) } function SettingRow({ setting, category, onUpdate, onRemove, onDuplicate, onTypeSwitch, }: { setting: SettingItem category: string onUpdate: ( cat: string, title: string, val: string | number | boolean, ) => void onRemove: (cat: string, title: string) => void onDuplicate: (cat: string, title: string) => void onTypeSwitch: (cat: string, title: string) => void }) { const dragControls = useDragControls() const typeMeta = getTypeMeta(setting.value) return ( {/* Drag Handle */} dragControls.start(e)} > {/* Type Badge — clickable to cycle type */} onTypeSwitch(category, setting.title)} className={`text-[9px] font-bold tracking-wider px-1 py-0.5 rounded border text-center leading-none cursor-pointer hover:brightness-110 transition-all ${typeMeta.className}`} title={`${typeMeta.title} — click to switch type`} > {typeMeta.label} {/* Label */} {setting.title} {/* Value Control */} {typeof setting.value === "boolean" ? ( onUpdate(category, setting.title, !setting.value) } className="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 cursor-pointer" style={{ backgroundColor: setting.value ? "var(--color-primary, #3b82f6)" : "var(--color-border, #374151)", }} > ) : typeof setting.value === "number" ? ( { const cleaned = e.target.value.replace( /[^0-9]/g, "", ) const updated = cleaned === "" ? 0 : Number(cleaned) onUpdate(category, setting.title, updated) }} className="w-full px-3 py-1.5 rounded-md border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors text-right" /> ) : ( { onUpdate(category, setting.title, e.target.value) }} className="w-full px-3 py-1.5 rounded-md border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" /> )} {/* Actions: Duplicate + Remove */} onDuplicate(category, setting.title)} className="p-1 rounded-md text-text/30 hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100" title="Duplicate setting" > onRemove(category, setting.title)} className="p-1 rounded-md text-text/30 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100" title="Remove setting" > ) } export function SettingsEditor({ value, onChange, className = "", }: SettingsEditorProps) { const [collapsedCategories, setCollapsedCategories] = useState>( new Set(), ) const [newCategoryName, setNewCategoryName] = useState("") const [newSettingNames, setNewSettingNames] = useState< Record >({}) const [newSettingTypes, setNewSettingTypes] = useState< Record >({}) const addSettingInputRefs = useRef>( {}, ) const [pasteOpenFor, setPasteOpenFor] = useState(null) const [pasteText, setPasteText] = useState("") const setAddSettingInputRef = useCallback( (category: string) => (el: HTMLInputElement | null) => { addSettingInputRefs.current[category] = el }, [], ) const toggleCategory = (category: string) => { setCollapsedCategories((prev) => { const next = new Set(prev) if (next.has(category)) { next.delete(category) } else { next.add(category) } return next }) } const collapseAll = () => { setCollapsedCategories(new Set(value.map((c) => c.category))) } const expandAll = () => { setCollapsedCategories(new Set()) } const allCollapsed = value.length > 0 && collapsedCategories.size === value.length const allExpanded = collapsedCategories.size === 0 const addCategory = () => { const name = newCategoryName.trim() if (!name) return if (value.some((c) => c.category === name)) return onChange([...value, { category: name, settings: [] }]) setNewCategoryName("") } const removeCategory = (category: string) => { onChange(value.filter((c) => c.category !== category)) setCollapsedCategories((prev) => { const next = new Set(prev) next.delete(category) return next }) } const addSetting = (category: string) => { const name = (newSettingNames[category] || "").trim() if (!name) return const type = newSettingTypes[category] || "text" const defaultValue = type === "boolean" ? false : type === "number" ? 0 : "" onChange( value.map((c) => { if (c.category !== category) return c if (c.settings.some((s) => s.title === name)) return c return { ...c, settings: [ ...c.settings, { title: name, value: defaultValue }, ], } }), ) setNewSettingNames((prev) => ({ ...prev, [category]: "" })) // Refocus after React re-renders setTimeout(() => addSettingInputRefs.current[category]?.focus(), 0) } const removeSetting = (category: string, settingTitle: string) => { onChange( value.map((c) => { if (c.category !== category) return c return { ...c, settings: c.settings.filter( (s) => s.title !== settingTitle, ), } }), ) } const duplicateSetting = (category: string, settingTitle: string) => { onChange( value.map((c) => { if (c.category !== category) return c const idx = c.settings.findIndex((s) => s.title === settingTitle) if (idx === -1) return c const original = c.settings[idx] // Find a unique title let copyTitle = `${original.title} (copy)` let n = 2 while (c.settings.some((s) => s.title === copyTitle)) { copyTitle = `${original.title} (copy ${n})` n++ } const next = [...c.settings] next.splice(idx + 1, 0, { title: copyTitle, value: original.value, }) return { ...c, settings: next } }), ) } const cycleSettingType = (category: string, settingTitle: string) => { onChange( value.map((c) => { if (c.category !== category) return c return { ...c, settings: c.settings.map((s) => { if (s.title !== settingTitle) return s const meta = getTypeMeta(s.value) return { ...s, value: convertValue(s.value, meta.next) } }), } }), ) } const updateSetting = ( category: string, settingTitle: string, newValue: string | number | boolean, ) => { onChange( value.map((c) => { if (c.category !== category) return c return { ...c, settings: c.settings.map((s) => s.title === settingTitle ? { ...s, value: newValue } : s, ), } }), ) } const handleReorderSettings = ( category: string, newSettings: SettingItem[], ) => { onChange( value.map((c) => { if (c.category !== category) return c return { ...c, settings: newSettings } }), ) } const parsePaste = (category: string) => { const lines = pasteText.split("\n") const newSettings: SettingItem[] = [] for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue // Match: Key = Value, Key: Value, Key - Value const match = trimmed.match(/^(.+?)\s*[:=\-]\s*(.+)$/) if (!match) continue const [, rawKey, rawVal] = match const key = rawKey.trim() const val = rawVal.trim() if (!key || !val) continue // Detect type const lower = val.toLowerCase() let parsedValue: string | number | boolean = val if ( lower === "true" || lower === "false" || lower === "on" || lower === "off" || lower === "yes" || lower === "no" ) { parsedValue = lower === "true" || lower === "on" || lower === "yes" } else if (/^-?\d+$/.test(val)) { parsedValue = Number(val) } newSettings.push({ title: key, value: parsedValue }) } if (newSettings.length === 0) return onChange( value.map((c) => { if (c.category !== category) return c const existingTitles = new Set(c.settings.map((s) => s.title)) const uniqueNew = newSettings.filter( (s) => !existingTitles.has(s.title), ) return { ...c, settings: [...c.settings, ...uniqueNew], } }), ) setPasteText("") setPasteOpenFor(null) } const loadDefaults = () => { onChange(DEFAULT_CATEGORIES) setCollapsedCategories(new Set()) } const isEmpty = value.length === 0 const [showCustomInput, setShowCustomInput] = useState(false) const customInputRef = useRef(null) useEffect(() => { if (showCustomInput && customInputRef.current) { customInputRef.current.focus() } }, [showCustomInput]) // Keyboard reorder: Alt + ArrowUp/ArrowDown const valueRef = useRef(value) const onChangeRef = useRef(onChange) useEffect(() => { valueRef.current = value onChangeRef.current = onChange }) useEffect(() => { const handler = (e: KeyboardEvent) => { if (!e.altKey) return if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return const active = document.activeElement if (!active) return const row = active.closest("[data-setting-row]") if (!row) return const category = row.getAttribute("data-category") const title = row.getAttribute("data-title") if (!category || !title) return e.preventDefault() const currentValue = valueRef.current const direction = e.key === "ArrowUp" ? -1 : 1 const nextValue = currentValue.map((c) => { if (c.category !== category) return c const idx = c.settings.findIndex((s) => s.title === title) if (idx === -1) return c const newIdx = idx + direction if (newIdx < 0 || newIdx >= c.settings.length) return c const nextSettings = [...c.settings] ;[nextSettings[idx], nextSettings[newIdx]] = [ nextSettings[newIdx], nextSettings[idx], ] return { ...c, settings: nextSettings } }) onChangeRef.current(nextValue) // Restore focus after React re-renders requestAnimationFrame(() => { const newRow = document.querySelector( `[data-setting-row][data-category="${category}"][data-title="${title}"]`, ) const input = newRow?.querySelector( "input, button", ) as HTMLElement | null input?.focus() }) } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, []) return ( {isEmpty && !showCustomInput && ( No settings configured Load defaults setShowCustomInput(true)} className="px-4 py-2 rounded-lg border border-border text-text text-sm font-semibold hover:bg-text/5 transition-colors cursor-pointer" > Create custom )} {isEmpty && showCustomInput && ( Category name setNewCategoryName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() if (newCategoryName.trim()) { addCategory() setShowCustomInput(false) } } }} placeholder="e.g. Graphics, Audio, Display..." className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" /> { if (newCategoryName.trim()) { addCategory() setShowCustomInput(false) } }} className="px-4 py-2.5 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer" > Add { setNewCategoryName("") setShowCustomInput(false) }} className="px-4 py-2.5 rounded-lg border border-border text-text/70 text-sm font-semibold hover:bg-text/5 transition-colors cursor-pointer" > Cancel )} {/* Global toolbar */} {value.length > 0 && ( Expand all Collapse all )} {value.map((cat) => { const isCollapsed = collapsedCategories.has( cat.category, ) return ( toggleCategory(cat.category)} onRemove={() => removeCategory(cat.category)} newSettingNames={newSettingNames} newSettingTypes={newSettingTypes} onNewSettingNameChange={(name) => setNewSettingNames((prev) => ({ ...prev, [cat.category]: name, })) } onNewSettingTypeChange={(type) => setNewSettingTypes((prev) => ({ ...prev, [cat.category]: type, })) } onAddSetting={() => addSetting(cat.category)} setAddSettingInputRef={setAddSettingInputRef} onUpdateSetting={updateSetting} onRemoveSetting={removeSetting} onDuplicateSetting={duplicateSetting} onTypeSwitch={cycleSettingType} onReorderSettings={(newSettings) => handleReorderSettings( cat.category, newSettings, ) } pasteOpen={pasteOpenFor === cat.category} pasteText={pasteText} onPasteTextChange={setPasteText} onTogglePaste={() => setPasteOpenFor((prev) => prev === cat.category ? null : cat.category, ) } onParsePaste={() => parsePaste(cat.category)} /> ) })} {!isEmpty && ( setNewCategoryName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() addCategory() } }} placeholder="New category name..." className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" /> Add )} ) } function CategoryCard({ cat, isCollapsed, onToggle, onRemove, newSettingNames, newSettingTypes, onNewSettingNameChange, onNewSettingTypeChange, onAddSetting, setAddSettingInputRef, onUpdateSetting, onRemoveSetting, onDuplicateSetting, onTypeSwitch, onReorderSettings, pasteOpen, pasteText, onPasteTextChange, onTogglePaste, onParsePaste, }: { cat: SettingCategory isCollapsed: boolean onToggle: () => void onRemove: () => void newSettingNames: Record newSettingTypes: Record onNewSettingNameChange: (name: string) => void onNewSettingTypeChange: (type: string) => void onAddSetting: () => void setAddSettingInputRef: ( category: string, ) => (el: HTMLInputElement | null) => void onUpdateSetting: ( cat: string, title: string, val: string | number | boolean, ) => void onRemoveSetting: (cat: string, title: string) => void onDuplicateSetting: (cat: string, title: string) => void onTypeSwitch: (cat: string, title: string) => void onReorderSettings: (newSettings: SettingItem[]) => void pasteOpen: boolean pasteText: string onPasteTextChange: (text: string) => void onTogglePaste: () => void onParsePaste: () => void }) { const dragControls = useDragControls() return ( {/* Category Header */} {/* Category Drag Handle */} dragControls.start(e)} > {/* Collapse Toggle */} {isCollapsed ? ( ) : ( )} {/* Title */} {cat.category} {cat.settings.length} {/* Remove Category */} {!isCollapsed && ( {/* Add setting row at the top */} {typeOptions.map((opt) => ( onNewSettingTypeChange( opt.value, ) } className={`px-2 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${ (newSettingTypes[ cat.category ] || "text") === opt.value ? "bg-primary text-white border-primary" : "border-border text-text/60 hover:bg-text/5" }`} > {opt.label} ))} onNewSettingNameChange(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() onAddSetting() } }} placeholder="Add setting..." className="flex-1 min-w-0 px-3 py-1.5 rounded-md border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" /> {/* Bulk paste textarea */} {pasteOpen && ( onPasteTextChange( e.target.value, ) } onKeyDown={(e) => { if ( e.key === "Enter" && e.metaKey ) { e.preventDefault() onParsePaste() } }} placeholder={`Paste settings like:\nTexture Quality = High\nShadow Quality = Medium\nVSync = Off`} rows={4} className="w-full px-3 py-2 rounded-md border border-border bg-background text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors resize-y" /> One setting per line. Use{" "} = ,{" "} : , or{" "} - {" "} as separators. Values like true/false become toggles, numbers become numeric. Press{" "} Cmd+Enter {" "} to add. Add {cat.settings.length > 0 ? "more" : ""} )} {/* Column header — visually aligns with setting rows */} {cat.settings.length > 0 && ( Type Setting Value )} {/* Settings list with drag-to-reorder */} {cat.settings.map((setting) => ( ))} {cat.settings.length === 0 && ( No settings yet — add one above )} )} ) }
No settings configured
One setting per line. Use{" "} = ,{" "} : , or{" "} - {" "} as separators. Values like true/false become toggles, numbers become numeric. Press{" "} Cmd+Enter {" "} to add.
=
:
-