Files
deckyvault/components/wizard/settings-editor.tsx
T

1040 lines
43 KiB
TypeScript

"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 (
<Reorder.Item
value={setting}
dragListener={false}
dragControls={dragControls}
as="div"
data-setting-row
data-category={category}
data-title={setting.title}
className="group grid grid-cols-[28px_36px_1fr_1fr_56px] gap-2 items-center rounded-lg border border-border bg-text/3 px-2 py-2 shadow-sm hover:shadow-md hover:bg-text/5 transition-shadow transition-colors"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.15 }}
whileDrag={{
scale: 1.01,
boxShadow: "0 8px 30px rgba(0,0,0,0.2)",
zIndex: 10,
}}
>
{/* Drag Handle */}
<div
className="cursor-grab active:cursor-grabbing p-0.5 rounded text-text/20 hover:text-text/50 hover:bg-text/5 transition-colors flex items-center justify-center select-none touch-none"
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
>
<GripVertical className="h-4 w-4" />
</div>
{/* Type Badge — clickable to cycle type */}
<button
type="button"
onClick={() => 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}
</button>
{/* Label */}
<span className="text-sm font-medium text-text/90 truncate px-1">
{setting.title}
</span>
{/* Value Control */}
<div className="min-w-0 flex items-center">
{typeof setting.value === "boolean" ? (
<button
onClick={() =>
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)",
}}
>
<span
className="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"
style={{
transform: setting.value
? "translateX(22px)"
: "translateX(2px)",
}}
/>
</button>
) : typeof setting.value === "number" ? (
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={setting.value}
onChange={(e) => {
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"
/>
) : (
<input
type="text"
value={setting.value}
onChange={(e) => {
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"
/>
)}
</div>
{/* Actions: Duplicate + Remove */}
<div className="flex items-center justify-end gap-0.5">
<button
onClick={() => 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"
>
<Copy className="h-3 w-3" />
</button>
<button
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</Reorder.Item>
)
}
export function SettingsEditor({
value,
onChange,
className = "",
}: SettingsEditorProps) {
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
new Set(),
)
const [newCategoryName, setNewCategoryName] = useState("")
const [newSettingNames, setNewSettingNames] = useState<
Record<string, string>
>({})
const [newSettingTypes, setNewSettingTypes] = useState<
Record<string, string>
>({})
const addSettingInputRefs = useRef<Record<string, HTMLInputElement | null>>(
{},
)
const [pasteOpenFor, setPasteOpenFor] = useState<string | null>(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<HTMLInputElement>(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 (
<div className={`space-y-4 ${className}`}>
{isEmpty && !showCustomInput && (
<div className="flex flex-col items-center justify-center py-10 border border-dashed border-border rounded-lg bg-text/5">
<p className="text-sm text-text/50 mb-4">
No settings configured
</p>
<div className="flex items-center gap-2">
<button
onClick={loadDefaults}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
>
Load defaults
</button>
<button
onClick={() => 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
</button>
</div>
</div>
)}
{isEmpty && showCustomInput && (
<div className="rounded-lg border border-dashed border-primary/50 bg-text/5 p-4">
<label className="text-xs font-medium text-text/60 mb-2 block">
Category name
</label>
<div className="flex items-center gap-2">
<input
ref={customInputRef}
type="text"
value={newCategoryName}
onChange={(e) => 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"
/>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
</div>
</div>
)}
{/* Global toolbar */}
{value.length > 0 && (
<div className="flex items-center justify-end gap-1">
<button
onClick={expandAll}
disabled={allExpanded}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-text/60 hover:text-text hover:bg-text/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1"
>
<ChevronUp className="h-3 w-3" />
Expand all
</button>
<button
onClick={collapseAll}
disabled={allCollapsed}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-text/60 hover:text-text hover:bg-text/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1"
>
<ChevronDown className="h-3 w-3" />
Collapse all
</button>
</div>
)}
<Reorder.Group
axis="y"
values={value}
onReorder={onChange}
as="div"
className="space-y-4"
>
<AnimatePresence initial={false}>
{value.map((cat) => {
const isCollapsed = collapsedCategories.has(
cat.category,
)
return (
<CategoryCard
key={cat.category}
cat={cat}
isCollapsed={isCollapsed}
onToggle={() => 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)}
/>
)
})}
</AnimatePresence>
</Reorder.Group>
{!isEmpty && (
<div className="flex items-center gap-2 pt-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => 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"
/>
<button
onClick={addCategory}
className="px-4 py-2.5 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add
</button>
</div>
)}
</div>
)
}
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<string, string>
newSettingTypes: Record<string, string>
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 (
<Reorder.Item
value={cat}
dragListener={false}
dragControls={dragControls}
as="div"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="border border-border rounded-xl overflow-hidden bg-background shadow-sm"
>
{/* Category Header */}
<div className="flex items-center gap-2 px-3 py-3 bg-text/3">
{/* Category Drag Handle */}
<div
className="cursor-grab active:cursor-grabbing p-1 rounded text-text/30 hover:text-text/60 hover:bg-text/5 transition-colors shrink-0 select-none touch-none"
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
>
<GripVertical className="h-4 w-4" />
</div>
{/* Collapse Toggle */}
<button
onClick={onToggle}
className="p-1 rounded text-text/50 hover:text-text hover:bg-text/5 transition-colors cursor-pointer shrink-0"
>
{isCollapsed ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</button>
{/* Title */}
<button
onClick={onToggle}
className="flex-1 text-left text-sm font-semibold text-text hover:text-text/80 transition-colors cursor-pointer flex items-center gap-2"
>
{cat.category}
<span className="text-xs text-text/40 font-normal bg-text/5 px-2 py-0.5 rounded-full">
{cat.settings.length}
</span>
</button>
{/* Remove Category */}
<button
onClick={onRemove}
className="p-1.5 rounded-md text-text/30 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer shrink-0"
title="Remove category"
>
<X className="h-4 w-4" />
</button>
</div>
<AnimatePresence initial={false}>
{!isCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3 pb-4 pt-3 space-y-3">
{/* Add setting row at the top */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex items-center gap-1 shrink-0">
{typeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() =>
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}
</button>
))}
</div>
<input
ref={setAddSettingInputRef(cat.category)}
type="text"
value={newSettingNames[cat.category] || ""}
onChange={(e) =>
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"
/>
<button
onClick={onAddSetting}
className="p-1.5 rounded-md bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer shrink-0"
title="Add setting"
>
<Plus className="h-4 w-4" />
</button>
<button
onClick={onTogglePaste}
className={`p-1.5 rounded-md border transition-colors cursor-pointer shrink-0 flex items-center gap-1 ${
pasteOpen
? "border-primary text-primary bg-primary/10"
: "border-border text-text/50 hover:text-text hover:bg-text/5"
}`}
title="Paste multiple settings"
>
<FileInput className="h-4 w-4" />
</button>
</div>
{/* Bulk paste textarea */}
<AnimatePresence>
{pasteOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="space-y-2 rounded-lg border border-dashed border-primary/30 bg-primary/5 p-3">
<textarea
value={pasteText}
onChange={(e) =>
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"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] text-text/40 leading-relaxed">
One setting per line. Use{" "}
<code className="text-text/60 bg-text/5 px-1 rounded">
=
</code>
,{" "}
<code className="text-text/60 bg-text/5 px-1 rounded">
:
</code>
, or{" "}
<code className="text-text/60 bg-text/5 px-1 rounded">
-
</code>{" "}
as separators. Values like
true/false become toggles,
numbers become numeric.
Press{" "}
<kbd className="text-text/60 bg-text/5 px-1 rounded">
Cmd+Enter
</kbd>{" "}
to add.
</p>
<button
onClick={onParsePaste}
disabled={
!pasteText.trim()
}
className="px-3 py-1.5 rounded-md bg-primary text-white text-xs font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
>
Add {cat.settings.length > 0 ? "more" : ""}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Column header — visually aligns with setting rows */}
{cat.settings.length > 0 && (
<div className="grid grid-cols-[28px_36px_1fr_1fr_56px] gap-2 items-center px-2 pb-1">
<div />
<div className="text-[9px] font-semibold text-text/25 text-center uppercase tracking-wider">
Type
</div>
<div className="text-[9px] font-semibold text-text/25 uppercase tracking-wider px-1">
Setting
</div>
<div className="text-[9px] font-semibold text-text/25 uppercase tracking-wider">
Value
</div>
<div />
</div>
)}
{/* Settings list with drag-to-reorder */}
<Reorder.Group
axis="y"
values={cat.settings}
onReorder={onReorderSettings}
as="div"
className="space-y-2"
>
<AnimatePresence initial={false}>
{cat.settings.map((setting) => (
<SettingRow
key={setting.title}
setting={setting}
category={cat.category}
onUpdate={onUpdateSetting}
onRemove={onRemoveSetting}
onDuplicate={onDuplicateSetting}
onTypeSwitch={onTypeSwitch}
/>
))}
</AnimatePresence>
</Reorder.Group>
{cat.settings.length === 0 && (
<div className="text-center py-6 text-xs text-text/30 italic border border-dashed border-border rounded-lg">
No settings yet add one above
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Reorder.Item>
)
}