mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 07:29:39 +02:00
- Integrate worldream (text-first world-building platform) into games/ - Configure as @worldream/web workspace package - Remove standalone git repo, now part of monorepo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
520 lines
15 KiB
Svelte
520 lines
15 KiB
Svelte
<script lang="ts">
|
|
import type {
|
|
CustomFieldSchema,
|
|
CustomFieldData,
|
|
CustomFieldDefinition,
|
|
} from '$lib/types/customFields';
|
|
import { getDefaultValueForType } from '$lib/types/customFields';
|
|
|
|
interface Props {
|
|
schema: CustomFieldSchema;
|
|
data?: CustomFieldData;
|
|
readonly?: boolean;
|
|
onChange?: (data: CustomFieldData) => void;
|
|
onSave?: (data: CustomFieldData) => void;
|
|
}
|
|
|
|
let { schema, data = {}, readonly = false, onChange, onSave }: Props = $props();
|
|
|
|
// Initialize form data with defaults
|
|
let formData = $state<CustomFieldData>({ ...data });
|
|
let isDirty = $state(false);
|
|
let errors = $state<Record<string, string>>({});
|
|
|
|
// Group fields by category
|
|
let fieldsByCategory = $derived(() => {
|
|
const categories = new Map<string, CustomFieldDefinition[]>();
|
|
|
|
// Add uncategorized fields first
|
|
const uncategorized = schema.fields.filter((f) => !f.category);
|
|
if (uncategorized.length > 0) {
|
|
categories.set('_uncategorized', uncategorized);
|
|
}
|
|
|
|
// Group by category
|
|
for (const field of schema.fields) {
|
|
if (field.category) {
|
|
if (!categories.has(field.category)) {
|
|
categories.set(field.category, []);
|
|
}
|
|
categories.get(field.category)!.push(field);
|
|
}
|
|
}
|
|
|
|
return categories;
|
|
});
|
|
|
|
// Initialize missing fields with defaults
|
|
$effect(() => {
|
|
for (const field of schema.fields) {
|
|
if (!(field.key in formData)) {
|
|
formData[field.key] = getDefaultValueForType(field.type, field.config);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Track changes
|
|
function handleFieldChange(key: string, value: any) {
|
|
formData = { ...formData, [key]: value };
|
|
isDirty = true;
|
|
errors = { ...errors, [key]: '' }; // Clear error on change
|
|
|
|
if (onChange) {
|
|
onChange(formData);
|
|
}
|
|
|
|
// Handle formula dependencies
|
|
updateDependentFormulas(key);
|
|
}
|
|
|
|
// Update formulas that depend on changed field
|
|
function updateDependentFormulas(changedKey: string) {
|
|
for (const field of schema.fields) {
|
|
if (field.type === 'formula' && field.config.dependencies?.includes(changedKey)) {
|
|
// TODO: Recalculate formula
|
|
// For now, just mark as needs recalculation
|
|
formData[field.key] = `[Recalculating...]`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate field
|
|
function validateField(field: CustomFieldDefinition, value: any): string | null {
|
|
// Required validation
|
|
if (field.required && (value === null || value === undefined || value === '')) {
|
|
return `${field.label} ist erforderlich`;
|
|
}
|
|
|
|
// Type-specific validation
|
|
switch (field.type) {
|
|
case 'number':
|
|
case 'range':
|
|
if (value !== null && value !== undefined) {
|
|
if (field.config.min !== undefined && value < field.config.min) {
|
|
return `Mindestwert ist ${field.config.min}`;
|
|
}
|
|
if (field.config.max !== undefined && value > field.config.max) {
|
|
return `Maximalwert ist ${field.config.max}`;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'text':
|
|
if (value && field.config.maxLength && value.length > field.config.maxLength) {
|
|
return `Maximal ${field.config.maxLength} Zeichen`;
|
|
}
|
|
if (value && field.config.pattern) {
|
|
const regex = new RegExp(field.config.pattern);
|
|
if (!regex.test(value)) {
|
|
return 'Ungültiges Format';
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'list':
|
|
if (Array.isArray(value)) {
|
|
if (field.config.min_items && value.length < field.config.min_items) {
|
|
return `Mindestens ${field.config.min_items} Elemente erforderlich`;
|
|
}
|
|
if (field.config.max_items && value.length > field.config.max_items) {
|
|
return `Maximal ${field.config.max_items} Elemente erlaubt`;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Validate all fields
|
|
function validateAll(): boolean {
|
|
let isValid = true;
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
for (const field of schema.fields) {
|
|
const error = validateField(field, formData[field.key]);
|
|
if (error) {
|
|
newErrors[field.key] = error;
|
|
isValid = false;
|
|
}
|
|
}
|
|
|
|
errors = newErrors;
|
|
return isValid;
|
|
}
|
|
|
|
// Handle save
|
|
function handleSave() {
|
|
if (validateAll() && onSave) {
|
|
onSave(formData);
|
|
isDirty = false;
|
|
}
|
|
}
|
|
|
|
// Render field based on type
|
|
function getFieldComponent(field: CustomFieldDefinition) {
|
|
const value = formData[field.key];
|
|
const error = errors[field.key];
|
|
|
|
switch (field.type) {
|
|
case 'text':
|
|
return renderTextField(field, value, error);
|
|
case 'number':
|
|
return renderNumberField(field, value, error);
|
|
case 'range':
|
|
return renderRangeField(field, value, error);
|
|
case 'select':
|
|
return renderSelectField(field, value, error);
|
|
case 'multiselect':
|
|
return renderMultiselectField(field, value, error);
|
|
case 'boolean':
|
|
return renderBooleanField(field, value, error);
|
|
case 'date':
|
|
return renderDateField(field, value, error);
|
|
case 'formula':
|
|
return renderFormulaField(field, value, error);
|
|
case 'list':
|
|
return renderListField(field, value, error);
|
|
case 'json':
|
|
return renderJsonField(field, value, error);
|
|
case 'reference':
|
|
return renderReferenceField(field, value, error);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Field renderers
|
|
function renderTextField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
if (field.config.multiline) {
|
|
return `
|
|
<textarea
|
|
value="${value || ''}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
|
${readonly ? 'disabled' : ''}
|
|
placeholder="${field.config.placeholder || ''}"
|
|
rows="3"
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
></textarea>
|
|
`;
|
|
}
|
|
return `
|
|
<input
|
|
type="text"
|
|
value="${value || ''}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
|
${readonly ? 'disabled' : ''}
|
|
placeholder="${field.config.placeholder || ''}"
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
/>
|
|
`;
|
|
}
|
|
|
|
function renderNumberField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
return `
|
|
<div class="flex items-center gap-2">
|
|
${field.config.prefix ? `<span class="text-sm text-theme-text-secondary">${field.config.prefix}</span>` : ''}
|
|
<input
|
|
type="number"
|
|
value="${value ?? field.config.default ?? ''}"
|
|
min="${field.config.min ?? ''}"
|
|
max="${field.config.max ?? ''}"
|
|
step="${field.config.step ?? 1}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="flex-1 px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
/>
|
|
${field.config.unit ? `<span class="text-sm text-theme-text-secondary">${field.config.unit}</span>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRangeField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
return `
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between text-sm">
|
|
<span>${field.config.min ?? 0}</span>
|
|
<span class="font-medium">${value ?? field.config.default ?? 0}</span>
|
|
<span>${field.config.max ?? 100}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
value="${value ?? field.config.default ?? 0}"
|
|
min="${field.config.min ?? 0}"
|
|
max="${field.config.max ?? 100}"
|
|
step="${field.config.step ?? 1}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="w-full disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSelectField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
const choices = field.config.choices || [];
|
|
return `
|
|
<select
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
>
|
|
<option value="">-- Wählen --</option>
|
|
${choices
|
|
.map(
|
|
(choice) => `
|
|
<option value="${choice.value}" ${value === choice.value ? 'selected' : ''}>
|
|
${choice.label}
|
|
</option>
|
|
`
|
|
)
|
|
.join('')}
|
|
</select>
|
|
`;
|
|
}
|
|
|
|
function renderMultiselectField(
|
|
field: CustomFieldDefinition,
|
|
value: any,
|
|
error: string | undefined
|
|
) {
|
|
const choices = field.config.choices || [];
|
|
const selectedValues = Array.isArray(value) ? value : [];
|
|
|
|
// For now, render as checkboxes
|
|
return choices
|
|
.map(
|
|
(choice) => `
|
|
<label class="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
value="${choice.value}"
|
|
${selectedValues.includes(choice.value) ? 'checked' : ''}
|
|
onchange="this.dispatchEvent(new CustomEvent('multiselectchange', { detail: { value: this.value, checked: this.checked } }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="disabled:opacity-50"
|
|
/>
|
|
<span class="text-sm">${choice.label}</span>
|
|
</label>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
function renderBooleanField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
return `
|
|
<label class="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
${value ? 'checked' : ''}
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.checked }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="disabled:opacity-50"
|
|
/>
|
|
<span class="text-sm">Aktiviert</span>
|
|
</label>
|
|
`;
|
|
}
|
|
|
|
function renderDateField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
return `
|
|
<input
|
|
type="date"
|
|
value="${value || ''}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
/>
|
|
`;
|
|
}
|
|
|
|
function renderFormulaField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
return `
|
|
<div class="p-3 bg-theme-elevated rounded-md">
|
|
<div class="text-sm text-theme-text-secondary mb-1">
|
|
Formel: ${field.config.formula}
|
|
</div>
|
|
<div class="font-medium">
|
|
${value ?? 'Wird berechnet...'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderListField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
const items = Array.isArray(value) ? value : [];
|
|
return `
|
|
<div class="space-y-2">
|
|
${items
|
|
.map(
|
|
(item, i) => `
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="${field.config.item_type === 'number' ? 'number' : 'text'}"
|
|
value="${item}"
|
|
onchange="this.dispatchEvent(new CustomEvent('listitemchange', { detail: { index: ${i}, value: this.value } }))"
|
|
${readonly ? 'disabled' : ''}
|
|
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface disabled:opacity-50"
|
|
/>
|
|
${
|
|
!readonly
|
|
? `
|
|
<button
|
|
onclick="this.dispatchEvent(new CustomEvent('listitemremove', { detail: ${i} }))"
|
|
class="text-theme-error hover:text-theme-error/80"
|
|
>
|
|
🗑️
|
|
</button>
|
|
`
|
|
: ''
|
|
}
|
|
</div>
|
|
`
|
|
)
|
|
.join('')}
|
|
${
|
|
!readonly && (!field.config.max_items || items.length < field.config.max_items)
|
|
? `
|
|
<button
|
|
onclick="this.dispatchEvent(new CustomEvent('listitemadd'))"
|
|
class="px-3 py-1 border border-theme-border-default rounded-md hover:bg-theme-elevated text-sm"
|
|
>
|
|
+ Element hinzufügen
|
|
</button>
|
|
`
|
|
: ''
|
|
}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderJsonField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
|
const jsonString = JSON.stringify(value, null, 2);
|
|
return `
|
|
<textarea
|
|
value="${jsonString}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: JSON.parse(this.value) }))"
|
|
${readonly ? 'disabled' : ''}
|
|
rows="5"
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface font-mono text-sm disabled:opacity-50"
|
|
></textarea>
|
|
`;
|
|
}
|
|
|
|
function renderReferenceField(
|
|
field: CustomFieldDefinition,
|
|
value: any,
|
|
error: string | undefined
|
|
) {
|
|
// For now, just render as text input
|
|
// In production, this would be a node selector
|
|
return `
|
|
<input
|
|
type="text"
|
|
value="${value || ''}"
|
|
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
|
${readonly ? 'disabled' : ''}
|
|
placeholder="Node-Slug eingeben"
|
|
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
|
rounded-md bg-theme-surface disabled:opacity-50"
|
|
/>
|
|
`;
|
|
}
|
|
</script>
|
|
|
|
<div class="custom-data-form space-y-6">
|
|
{#each fieldsByCategory() as [category, fields]}
|
|
<div class="category-group">
|
|
{#if category !== '_uncategorized'}
|
|
<h3 class="text-lg font-medium mb-3 text-theme-text-primary">
|
|
{category}
|
|
</h3>
|
|
{/if}
|
|
|
|
<div class="space-y-4">
|
|
{#each fields as field}
|
|
<div class="field-wrapper">
|
|
<label class="block text-sm font-medium mb-1 text-theme-text-primary">
|
|
{field.label}
|
|
{#if field.required}
|
|
<span class="text-theme-error">*</span>
|
|
{/if}
|
|
</label>
|
|
|
|
{#if field.description}
|
|
<p class="text-xs text-theme-text-secondary mb-2">
|
|
{field.description}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Field Component -->
|
|
<div
|
|
class="field-component"
|
|
onfieldchange={(e: CustomEvent) => handleFieldChange(field.key, e.detail)}
|
|
onmultiselectchange={(e: CustomEvent) => {
|
|
const current = formData[field.key] || [];
|
|
if (e.detail.checked) {
|
|
handleFieldChange(field.key, [...current, e.detail.value]);
|
|
} else {
|
|
handleFieldChange(
|
|
field.key,
|
|
current.filter((v) => v !== e.detail.value)
|
|
);
|
|
}
|
|
}}
|
|
onlistitemchange={(e: CustomEvent) => {
|
|
const items = [...(formData[field.key] || [])];
|
|
items[e.detail.index] = e.detail.value;
|
|
handleFieldChange(field.key, items);
|
|
}}
|
|
onlistitemremove={(e: CustomEvent) => {
|
|
const items = [...(formData[field.key] || [])];
|
|
items.splice(e.detail, 1);
|
|
handleFieldChange(field.key, items);
|
|
}}
|
|
onlistitemadd={() => {
|
|
const items = [...(formData[field.key] || [])];
|
|
items.push(getDefaultValueForType(field.config.item_type || 'text'));
|
|
handleFieldChange(field.key, items);
|
|
}}
|
|
>
|
|
{@html getFieldComponent(field)}
|
|
</div>
|
|
|
|
{#if errors[field.key]}
|
|
<p class="text-sm text-theme-error mt-1">
|
|
{errors[field.key]}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if onSave && !readonly}
|
|
<div class="flex justify-end gap-3 pt-4 border-t">
|
|
<button
|
|
onclick={handleSave}
|
|
disabled={!isDirty}
|
|
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 disabled:opacity-50"
|
|
>
|
|
Speichern
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.field-component :global(input),
|
|
.field-component :global(select),
|
|
.field-component :global(textarea) {
|
|
font-size: 0.875rem;
|
|
}
|
|
</style>
|