764 lines
28 KiB
TypeScript
764 lines
28 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { nanoid } from 'nanoid'
|
|
import type { Character, Variable } from '@/types/flowchart'
|
|
import ImportFromProjectModal from './ImportFromProjectModal'
|
|
|
|
type Tab = 'characters' | 'variables'
|
|
|
|
type ImportModalState = { open: boolean; mode: 'characters' | 'variables' }
|
|
|
|
type ProjectSettingsModalProps = {
|
|
projectId: string
|
|
characters: Character[]
|
|
variables: Variable[]
|
|
onCharactersChange: (characters: Character[]) => void
|
|
onVariablesChange: (variables: Variable[]) => void
|
|
onClose: () => void
|
|
getCharacterUsageCount: (characterId: string) => number
|
|
getVariableUsageCount: (variableId: string) => number
|
|
}
|
|
|
|
function randomHexColor(): string {
|
|
const colors = [
|
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
|
'#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7',
|
|
'#ec4899', '#f43f5e',
|
|
]
|
|
return colors[Math.floor(Math.random() * colors.length)]
|
|
}
|
|
|
|
export default function ProjectSettingsModal({
|
|
projectId,
|
|
characters,
|
|
variables,
|
|
onCharactersChange,
|
|
onVariablesChange,
|
|
onClose,
|
|
getCharacterUsageCount,
|
|
getVariableUsageCount,
|
|
}: ProjectSettingsModalProps) {
|
|
const [activeTab, setActiveTab] = useState<Tab>('characters')
|
|
const [importModal, setImportModal] = useState<ImportModalState>({ open: false, mode: 'characters' })
|
|
|
|
const handleImportCharacters = (imported: Character[]) => {
|
|
onCharactersChange([...characters, ...imported])
|
|
}
|
|
|
|
const handleImportVariables = (imported: Variable[]) => {
|
|
onVariablesChange([...variables, ...imported])
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/50"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="relative flex h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
|
Project Settings
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-zinc-200 px-6 dark:border-zinc-700">
|
|
<button
|
|
onClick={() => setActiveTab('characters')}
|
|
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
activeTab === 'characters'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
Characters
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('variables')}
|
|
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
activeTab === 'variables'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
Variables
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
{activeTab === 'characters' && (
|
|
<CharactersTab
|
|
characters={characters}
|
|
onChange={onCharactersChange}
|
|
getUsageCount={getCharacterUsageCount}
|
|
onImport={() => setImportModal({ open: true, mode: 'characters' })}
|
|
/>
|
|
)}
|
|
{activeTab === 'variables' && (
|
|
<VariablesTab
|
|
variables={variables}
|
|
onChange={onVariablesChange}
|
|
getUsageCount={getVariableUsageCount}
|
|
onImport={() => setImportModal({ open: true, mode: 'variables' })}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{importModal.open && (
|
|
<ImportFromProjectModal
|
|
mode={importModal.mode}
|
|
currentProjectId={projectId}
|
|
existingCharacters={characters}
|
|
existingVariables={variables}
|
|
onImportCharacters={handleImportCharacters}
|
|
onImportVariables={handleImportVariables}
|
|
onClose={() => setImportModal({ open: false, mode: importModal.mode })}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Characters Tab
|
|
type CharactersTabProps = {
|
|
characters: Character[]
|
|
onChange: (characters: Character[]) => void
|
|
getUsageCount: (characterId: string) => number
|
|
onImport: () => void
|
|
}
|
|
|
|
type CharacterFormData = {
|
|
name: string
|
|
color: string
|
|
description: string
|
|
}
|
|
|
|
function CharactersTab({ characters, onChange, getUsageCount, onImport }: CharactersTabProps) {
|
|
const [isAdding, setIsAdding] = useState(false)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
|
|
const resetForm = () => {
|
|
setFormData({ name: '', color: randomHexColor(), description: '' })
|
|
setFormError(null)
|
|
}
|
|
|
|
const validateName = (name: string, excludeId?: string): boolean => {
|
|
if (!name.trim()) {
|
|
setFormError('Name is required')
|
|
return false
|
|
}
|
|
const duplicate = characters.find(
|
|
(c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId
|
|
)
|
|
if (duplicate) {
|
|
setFormError('A character with this name already exists')
|
|
return false
|
|
}
|
|
setFormError(null)
|
|
return true
|
|
}
|
|
|
|
const handleAdd = () => {
|
|
if (!validateName(formData.name)) return
|
|
const newCharacter: Character = {
|
|
id: nanoid(),
|
|
name: formData.name.trim(),
|
|
color: formData.color,
|
|
description: formData.description.trim() || undefined,
|
|
}
|
|
onChange([...characters, newCharacter])
|
|
setIsAdding(false)
|
|
resetForm()
|
|
}
|
|
|
|
const handleEdit = (character: Character) => {
|
|
setEditingId(character.id)
|
|
setFormData({
|
|
name: character.name,
|
|
color: character.color,
|
|
description: character.description || '',
|
|
})
|
|
setFormError(null)
|
|
setIsAdding(false)
|
|
}
|
|
|
|
const handleSaveEdit = () => {
|
|
if (!editingId) return
|
|
if (!validateName(formData.name, editingId)) return
|
|
onChange(
|
|
characters.map((c) =>
|
|
c.id === editingId
|
|
? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined }
|
|
: c
|
|
)
|
|
)
|
|
setEditingId(null)
|
|
resetForm()
|
|
}
|
|
|
|
const handleDelete = (id: string) => {
|
|
const usageCount = getUsageCount(id)
|
|
if (usageCount > 0 && deleteConfirm !== id) {
|
|
setDeleteConfirm(id)
|
|
return
|
|
}
|
|
onChange(characters.filter((c) => c.id !== id))
|
|
setDeleteConfirm(null)
|
|
}
|
|
|
|
const handleCancelForm = () => {
|
|
setIsAdding(false)
|
|
setEditingId(null)
|
|
resetForm()
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
Define characters that can be referenced in dialogue nodes.
|
|
</p>
|
|
{!isAdding && !editingId && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onImport}
|
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
|
>
|
|
Import from project
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
|
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
Add Character
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Character List */}
|
|
<div className="space-y-2">
|
|
{characters.map((character) => (
|
|
<div key={character.id}>
|
|
{editingId === character.id ? (
|
|
<CharacterForm
|
|
formData={formData}
|
|
formError={formError}
|
|
onChange={setFormData}
|
|
onSave={handleSaveEdit}
|
|
onCancel={handleCancelForm}
|
|
saveLabel="Save"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="h-4 w-4 rounded-full"
|
|
style={{ backgroundColor: character.color }}
|
|
/>
|
|
<div>
|
|
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
|
{character.name}
|
|
</span>
|
|
{character.description && (
|
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
{character.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{deleteConfirm === character.id && (
|
|
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
|
Used in {getUsageCount(character.id)} node(s). Delete anyway?
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleEdit(character)}
|
|
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(character.id)}
|
|
className={`rounded px-2 py-1 text-sm ${
|
|
deleteConfirm === character.id
|
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
|
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{characters.length === 0 && !isAdding && (
|
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
|
No characters defined yet. Click "Add Character" to create one.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Form */}
|
|
{isAdding && (
|
|
<div className="mt-4">
|
|
<CharacterForm
|
|
formData={formData}
|
|
formError={formError}
|
|
onChange={setFormData}
|
|
onSave={handleAdd}
|
|
onCancel={handleCancelForm}
|
|
saveLabel="Add"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Character Form
|
|
type CharacterFormProps = {
|
|
formData: CharacterFormData
|
|
formError: string | null
|
|
onChange: (data: CharacterFormData) => void
|
|
onSave: () => void
|
|
onCancel: () => void
|
|
saveLabel: string
|
|
}
|
|
|
|
function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) {
|
|
return (
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Color
|
|
</label>
|
|
<input
|
|
type="color"
|
|
value={formData.color}
|
|
onChange={(e) => onChange({ ...formData, color: e.target.value })}
|
|
className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
|
placeholder="Character name"
|
|
autoFocus
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Description
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.description}
|
|
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
|
placeholder="Optional description"
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
</div>
|
|
{formError && (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={onCancel}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onSave}
|
|
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
{saveLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Variables Tab
|
|
type VariablesTabProps = {
|
|
variables: Variable[]
|
|
onChange: (variables: Variable[]) => void
|
|
getUsageCount: (variableId: string) => number
|
|
onImport: () => void
|
|
}
|
|
|
|
type VariableType = 'numeric' | 'string' | 'boolean'
|
|
|
|
type VariableFormData = {
|
|
name: string
|
|
type: VariableType
|
|
initialValue: string
|
|
description: string
|
|
}
|
|
|
|
const defaultInitialValues: Record<VariableType, string> = {
|
|
numeric: '0',
|
|
string: '',
|
|
boolean: 'false',
|
|
}
|
|
|
|
function parseInitialValue(type: VariableType, raw: string): number | string | boolean {
|
|
switch (type) {
|
|
case 'numeric':
|
|
return Number(raw) || 0
|
|
case 'boolean':
|
|
return raw === 'true'
|
|
case 'string':
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function VariablesTab({ variables, onChange, getUsageCount, onImport }: VariablesTabProps) {
|
|
const [isAdding, setIsAdding] = useState(false)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
|
|
const resetForm = () => {
|
|
setFormData({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
|
setFormError(null)
|
|
}
|
|
|
|
const validateName = (name: string, excludeId?: string): boolean => {
|
|
if (!name.trim()) {
|
|
setFormError('Name is required')
|
|
return false
|
|
}
|
|
const duplicate = variables.find(
|
|
(v) => v.name.toLowerCase() === name.trim().toLowerCase() && v.id !== excludeId
|
|
)
|
|
if (duplicate) {
|
|
setFormError('A variable with this name already exists')
|
|
return false
|
|
}
|
|
setFormError(null)
|
|
return true
|
|
}
|
|
|
|
const handleAdd = () => {
|
|
if (!validateName(formData.name)) return
|
|
const newVariable: Variable = {
|
|
id: nanoid(),
|
|
name: formData.name.trim(),
|
|
type: formData.type,
|
|
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
|
description: formData.description.trim() || undefined,
|
|
}
|
|
onChange([...variables, newVariable])
|
|
setIsAdding(false)
|
|
resetForm()
|
|
}
|
|
|
|
const handleEdit = (variable: Variable) => {
|
|
setEditingId(variable.id)
|
|
setFormData({
|
|
name: variable.name,
|
|
type: variable.type,
|
|
initialValue: String(variable.initialValue),
|
|
description: variable.description || '',
|
|
})
|
|
setFormError(null)
|
|
setIsAdding(false)
|
|
}
|
|
|
|
const handleSaveEdit = () => {
|
|
if (!editingId) return
|
|
if (!validateName(formData.name, editingId)) return
|
|
onChange(
|
|
variables.map((v) =>
|
|
v.id === editingId
|
|
? {
|
|
...v,
|
|
name: formData.name.trim(),
|
|
type: formData.type,
|
|
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
|
description: formData.description.trim() || undefined,
|
|
}
|
|
: v
|
|
)
|
|
)
|
|
setEditingId(null)
|
|
resetForm()
|
|
}
|
|
|
|
const handleDelete = (id: string) => {
|
|
const usageCount = getUsageCount(id)
|
|
if (usageCount > 0 && deleteConfirm !== id) {
|
|
setDeleteConfirm(id)
|
|
return
|
|
}
|
|
onChange(variables.filter((v) => v.id !== id))
|
|
setDeleteConfirm(null)
|
|
}
|
|
|
|
const handleCancelForm = () => {
|
|
setIsAdding(false)
|
|
setEditingId(null)
|
|
resetForm()
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
Define variables that can be referenced in variable nodes and edge conditions.
|
|
</p>
|
|
{!isAdding && !editingId && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onImport}
|
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
|
>
|
|
Import from project
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
|
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
Add Variable
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Variable List */}
|
|
<div className="space-y-2">
|
|
{variables.map((variable) => (
|
|
<div key={variable.id}>
|
|
{editingId === variable.id ? (
|
|
<VariableForm
|
|
formData={formData}
|
|
formError={formError}
|
|
onChange={setFormData}
|
|
onSave={handleSaveEdit}
|
|
onCancel={handleCancelForm}
|
|
saveLabel="Save"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
|
variable.type === 'numeric'
|
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
|
: variable.type === 'string'
|
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
|
}`}>
|
|
{variable.type}
|
|
</span>
|
|
<div>
|
|
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
|
{variable.name}
|
|
</span>
|
|
{variable.description && (
|
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
{variable.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
Initial: {String(variable.initialValue)}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
{deleteConfirm === variable.id && (
|
|
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
|
Used in {getUsageCount(variable.id)} node(s). Delete anyway?
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleEdit(variable)}
|
|
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(variable.id)}
|
|
className={`rounded px-2 py-1 text-sm ${
|
|
deleteConfirm === variable.id
|
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
|
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{variables.length === 0 && !isAdding && (
|
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
|
No variables defined yet. Click "Add Variable" to create one.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Form */}
|
|
{isAdding && (
|
|
<div className="mt-4">
|
|
<VariableForm
|
|
formData={formData}
|
|
formError={formError}
|
|
onChange={setFormData}
|
|
onSave={handleAdd}
|
|
onCancel={handleCancelForm}
|
|
saveLabel="Add"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Variable Form
|
|
type VariableFormProps = {
|
|
formData: VariableFormData
|
|
formError: string | null
|
|
onChange: (data: VariableFormData) => void
|
|
onSave: () => void
|
|
onCancel: () => void
|
|
saveLabel: string
|
|
}
|
|
|
|
function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) {
|
|
const handleTypeChange = (newType: VariableType) => {
|
|
onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] })
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
|
placeholder="Variable name"
|
|
autoFocus
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Type *
|
|
</label>
|
|
<select
|
|
value={formData.type}
|
|
onChange={(e) => handleTypeChange(e.target.value as VariableType)}
|
|
className="block rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
|
|
>
|
|
<option value="numeric">Numeric</option>
|
|
<option value="string">String</option>
|
|
<option value="boolean">Boolean</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Initial Value *
|
|
</label>
|
|
{formData.type === 'boolean' ? (
|
|
<select
|
|
value={formData.initialValue}
|
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
|
|
>
|
|
<option value="false">false</option>
|
|
<option value="true">true</option>
|
|
</select>
|
|
) : formData.type === 'numeric' ? (
|
|
<input
|
|
type="number"
|
|
value={formData.initialValue}
|
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={formData.initialValue}
|
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
placeholder="Initial value"
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
Description
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.description}
|
|
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
|
placeholder="Optional description"
|
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
</div>
|
|
{formError && (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={onCancel}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onSave}
|
|
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
{saveLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|