🔀 merge: integrate till-dev into main

Merge till-dev branch containing:
- Planta plant care tracking application
- Clock backend with alarms, timers, world clocks
- Zitare backend with favorites and lists
- Various app improvements and fixes
- Auth system updates
- Infrastructure improvements

Note: Some type-check issues may need resolution after merge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-18 15:40:43 +01:00
commit 49a8c652da
475 changed files with 28008 additions and 22742 deletions

View file

@ -0,0 +1,268 @@
<script lang="ts">
import { AuthService } from '../../services/AuthService';
import { createEventDispatcher } from 'svelte';
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
const dispatch = createEventDispatcher();
// Formular-Zustände
let email = '';
let password = '';
let confirmPassword = '';
let isLoading = false;
let errorMessage = '';
let successMessage = '';
// Formular absenden
async function handleSubmit() {
// Validierung
if (!email || !password || !confirmPassword) {
errorMessage = 'Bitte fülle alle Felder aus.';
return;
}
if (password !== confirmPassword) {
errorMessage = 'Die Passwörter stimmen nicht überein.';
return;
}
if (password.length < 6) {
errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.';
return;
}
try {
isLoading = true;
errorMessage = '';
const success = await AuthService.register(email, password);
if (success) {
successMessage =
'Registrierung erfolgreich! Bitte überprüfe deine E-Mails, um dein Konto zu bestätigen.';
// Formular zurücksetzen
email = '';
password = '';
confirmPassword = '';
// Nach kurzer Verzögerung zum Login wechseln
setTimeout(() => {
dispatch('switchView', 'login');
}, 3000);
} else {
errorMessage =
'Registrierung fehlgeschlagen. Bitte versuche es mit einer anderen E-Mail-Adresse.';
}
} catch (error) {
errorMessage = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
console.error('Registration error:', error);
} finally {
isLoading = false;
}
}
// Zum Login wechseln
function switchToLogin() {
dispatch('switchView', 'login');
}
</script>
<div class="auth-form-container">
<h2>Registrieren</h2>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
{#if successMessage}
<div class="success-message">
{successMessage}
</div>
{/if}
<form on:submit|preventDefault={handleSubmit}>
<div class="form-group">
<label for="email">E-Mail</label>
<input
type="email"
id="email"
bind:value={email}
placeholder="deine@email.de"
disabled={isLoading}
required
/>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input
type="password"
id="password"
bind:value={password}
placeholder="Mindestens 6 Zeichen"
disabled={isLoading}
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">Passwort bestätigen</label>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
placeholder="Passwort wiederholen"
disabled={isLoading}
required
/>
</div>
<button type="submit" class="auth-button" disabled={isLoading}>
{isLoading ? 'Wird registriert...' : 'Registrieren'}
</button>
</form>
<div class="auth-links">
<div class="login-link">
Bereits ein Konto?
<button type="button" class="text-button" on:click={switchToLogin} disabled={isLoading}>
Anmelden
</button>
</div>
</div>
</div>
<style>
.auth-form-container {
background-color: rgba(42, 50, 66, 0.9);
border-radius: 8px;
padding: 24px;
width: 100%;
max-width: 400px;
min-width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
}
h2 {
color: white;
margin-top: 0;
margin-bottom: 24px;
text-align: center;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
color: white;
margin-bottom: 8px;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: rgba(30, 36, 48, 0.8);
color: white;
font-size: 1rem;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
box-sizing: border-box;
}
input:focus {
border-color: #3182ce;
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
}
input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.auth-button {
width: 100%;
padding: 12px;
background-color: #3182ce;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
cursor: pointer;
transition: background-color 0.2s;
margin-top: 8px;
}
.auth-button:hover {
background-color: #2b6cb0;
}
.auth-button:disabled {
background-color: #64748b;
cursor: not-allowed;
}
.error-message {
background-color: rgba(220, 38, 38, 0.2);
color: #fca5a5;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.success-message {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.auth-links {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.text-button {
background: none;
border: none;
color: #63b3ed;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
text-decoration: underline;
transition: color 0.2s;
}
.text-button:hover {
color: #90cdf4;
}
.text-button:disabled {
color: #64748b;
cursor: not-allowed;
}
.login-link {
color: white;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 4px;
}
</style>

View file

@ -0,0 +1,517 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { AuthService } from '../../services/AuthService';
import { LevelService } from '../../services/LevelService';
import type { LevelMetadata } from '../../types/level.types';
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
const dispatch = createEventDispatcher();
// Benutzer-Daten
let user: any = null;
let userLevels: LevelMetadata[] = [];
let isLoading = true;
let errorMessage = '';
// Passwort-Änderung
let showPasswordChange = false;
let newPassword = '';
let confirmNewPassword = '';
let passwordChangeError = '';
let passwordChangeSuccess = '';
let isPasswordChanging = false;
// Beim Mounten Benutzerdaten laden
onMount(async () => {
await loadUserData();
});
// Benutzerdaten laden
async function loadUserData() {
try {
isLoading = true;
errorMessage = '';
// Aktuellen Benutzer abrufen
user = await AuthService.getCurrentUser();
if (user) {
// Levels des Benutzers laden
userLevels = await LevelService.getUserLevels();
} else {
errorMessage = 'Du bist nicht angemeldet.';
}
} catch (error) {
console.error('Error loading user data:', error);
errorMessage = 'Fehler beim Laden der Benutzerdaten.';
} finally {
isLoading = false;
}
}
// Passwort ändern
async function handlePasswordChange() {
// Validierung
if (!newPassword || !confirmNewPassword) {
passwordChangeError = 'Bitte fülle alle Felder aus.';
return;
}
if (newPassword !== confirmNewPassword) {
passwordChangeError = 'Die Passwörter stimmen nicht überein.';
return;
}
if (newPassword.length < 6) {
passwordChangeError = 'Das Passwort muss mindestens 6 Zeichen lang sein.';
return;
}
try {
isPasswordChanging = true;
passwordChangeError = '';
const success = await AuthService.updatePassword(newPassword);
if (success) {
passwordChangeSuccess = 'Passwort erfolgreich geändert.';
newPassword = '';
confirmNewPassword = '';
// Nach kurzer Verzögerung Passwort-Änderung ausblenden
setTimeout(() => {
showPasswordChange = false;
passwordChangeSuccess = '';
}, 3000);
} else {
passwordChangeError = 'Fehler beim Ändern des Passworts.';
}
} catch (error) {
console.error('Password change error:', error);
passwordChangeError = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
} finally {
isPasswordChanging = false;
}
}
// Abmelden
async function handleLogout() {
const success = await AuthService.logout();
if (success) {
dispatch('logout');
}
}
// Level bearbeiten
function editLevel(levelId: string) {
dispatch('editLevel', levelId);
}
// Level löschen
async function deleteLevel(levelId: string) {
if (confirm('Möchtest du dieses Level wirklich löschen?')) {
const success = await LevelService.deleteLevel(levelId);
if (success) {
// Levels neu laden
userLevels = await LevelService.getUserLevels();
}
}
}
// Level öffentlich/privat umschalten
function toggleLevelVisibility(level: LevelMetadata) {
// Hier würde die Logik zum Umschalten der Sichtbarkeit implementiert werden
// Da wir keinen direkten Zugriff auf die Funktion haben, müsste diese im LevelService ergänzt werden
console.log('Toggle visibility for level:', level.id);
}
</script>
<div class="profile-container">
<h2>Dein Profil</h2>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
{#if isLoading}
<div class="loading">Daten werden geladen...</div>
{:else if user}
<div class="user-info">
<div class="user-email">
<span class="label">E-Mail:</span>
<span class="value">{user.email}</span>
</div>
<div class="account-actions">
<button
type="button"
class="action-button secondary"
on:click={() => (showPasswordChange = !showPasswordChange)}
>
{showPasswordChange ? 'Abbrechen' : 'Passwort ändern'}
</button>
<button type="button" class="action-button" on:click={handleLogout}> Abmelden </button>
</div>
{#if showPasswordChange}
<div class="password-change-form">
<h3>Passwort ändern</h3>
{#if passwordChangeError}
<div class="error-message">
{passwordChangeError}
</div>
{/if}
{#if passwordChangeSuccess}
<div class="success-message">
{passwordChangeSuccess}
</div>
{/if}
<div class="form-group">
<label for="newPassword">Neues Passwort</label>
<input
type="password"
id="newPassword"
bind:value={newPassword}
placeholder="Mindestens 6 Zeichen"
disabled={isPasswordChanging}
/>
</div>
<div class="form-group">
<label for="confirmNewPassword">Passwort bestätigen</label>
<input
type="password"
id="confirmNewPassword"
bind:value={confirmNewPassword}
placeholder="Passwort wiederholen"
disabled={isPasswordChanging}
/>
</div>
<button
type="button"
class="action-button"
on:click={handlePasswordChange}
disabled={isPasswordChanging}
>
{isPasswordChanging ? 'Wird geändert...' : 'Passwort ändern'}
</button>
</div>
{/if}
</div>
<div class="user-levels">
<h3>Deine Levels</h3>
{#if userLevels.length === 0}
<div class="no-levels">Du hast noch keine Levels erstellt.</div>
{:else}
<div class="levels-list">
{#each userLevels as level}
<div class="level-card">
<div class="level-info">
<h4 class="level-name">{level.name}</h4>
<p class="level-description">{level.description || 'Keine Beschreibung'}</p>
<div class="level-stats">
<span class="stat">
<i class="icon">👁️</i>
{level.playCount}
</span>
<span class="stat">
<i class="icon">❤️</i>
{level.likesCount}
</span>
<span class="stat">
<i class="icon">🏷️</i>
{level.difficulty || 'Normal'}
</span>
</div>
</div>
<div class="level-actions">
<button
type="button"
class="icon-button"
on:click={() => editLevel(level.id)}
title="Level bearbeiten"
>
✏️
</button>
<button
type="button"
class="icon-button"
on:click={() => toggleLevelVisibility(level)}
title={level.isPublic ? 'Auf privat setzen' : 'Öffentlich machen'}
>
{level.isPublic ? '🔒' : '🌐'}
</button>
<button
type="button"
class="icon-button delete"
on:click={() => deleteLevel(level.id)}
title="Level löschen"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="not-logged-in">
Du bist nicht angemeldet. Bitte melde dich an, um dein Profil zu sehen.
</div>
{/if}
</div>
<style>
.profile-container {
background-color: rgba(42, 50, 66, 0.9);
border-radius: 8px;
padding: 24px;
width: 100%;
max-width: 800px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
color: white;
}
h2 {
margin-top: 0;
margin-bottom: 24px;
text-align: center;
font-size: 1.5rem;
}
h3 {
margin-top: 24px;
margin-bottom: 16px;
font-size: 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 8px;
}
.user-info {
background-color: rgba(30, 36, 48, 0.8);
padding: 16px;
border-radius: 6px;
margin-bottom: 24px;
}
.user-email {
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.label {
font-weight: bold;
color: rgba(255, 255, 255, 0.7);
}
.value {
font-size: 1.1rem;
}
.account-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.action-button {
padding: 10px 16px;
background-color: #3182ce;
color: white;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.action-button:hover {
background-color: #2b6cb0;
}
.action-button:disabled {
background-color: #64748b;
cursor: not-allowed;
}
.action-button.secondary {
background-color: #4a5568;
}
.action-button.secondary:hover {
background-color: #2d3748;
}
.password-change-form {
margin-top: 24px;
padding: 16px;
background-color: rgba(49, 130, 206, 0.1);
border-radius: 6px;
border-left: 3px solid #3182ce;
}
.password-change-form h3 {
margin-top: 0;
margin-bottom: 16px;
border-bottom: none;
padding-bottom: 0;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: rgba(30, 36, 48, 0.8);
color: white;
font-size: 1rem;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
input:focus {
border-color: #3182ce;
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
}
input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.error-message {
background-color: rgba(220, 38, 38, 0.2);
color: #fca5a5;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.success-message {
background-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.loading,
.no-levels,
.not-logged-in {
padding: 16px;
text-align: center;
background-color: rgba(30, 36, 48, 0.8);
border-radius: 6px;
margin-bottom: 16px;
}
.levels-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.level-card {
background-color: rgba(30, 36, 48, 0.8);
border-radius: 6px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.level-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.level-name {
margin-top: 0;
margin-bottom: 8px;
font-size: 1.1rem;
}
.level-description {
color: rgba(255, 255, 255, 0.7);
margin-bottom: 12px;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.level-stats {
display: flex;
gap: 12px;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
.stat {
display: flex;
align-items: center;
gap: 4px;
}
.icon {
font-style: normal;
}
.level-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.icon-button {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.icon-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.icon-button.delete:hover {
background-color: rgba(220, 38, 38, 0.2);
}
</style>

View file

@ -0,0 +1,83 @@
/**
* Repräsentiert einen einzelnen Block im Voxel-Spiel
*/
export interface Block {
/** X-Koordinate des Blocks */
x: number;
/** Y-Koordinate des Blocks */
y: number;
/** Z-Koordinate des Blocks */
z: number;
/** Typ des Blocks (z.B. 'grass', 'stone', 'lava') */
type: string;
/** Gibt an, ob dieser Block ein Spawn-Punkt ist */
isSpawnPoint?: boolean;
/** Gibt an, ob dieser Block ein Ziel ist */
isGoal?: boolean;
}
/**
* Repräsentiert die Größe der Spielwelt
*/
export interface WorldSize {
/** Breite der Welt in Blöcken */
width: number;
/** Höhe der Welt in Blöcken */
height: number;
/** Tiefe der Welt in Blöcken */
depth: number;
}
/**
* Repräsentiert die Position des Spawn-Punkts
*/
export interface SpawnPoint {
/** X-Koordinate des Spawn-Punkts */
x: number;
/** Y-Koordinate des Spawn-Punkts */
y: number;
/** Z-Koordinate des Spawn-Punkts */
z: number;
}
/**
* Repräsentiert die Metadaten eines Levels (ohne Blockdaten)
*/
export interface LevelMetadata {
/** Eindeutige ID des Levels */
id: string;
/** Name des Levels */
name: string;
/** Beschreibung des Levels */
description: string;
/** ID des Benutzers, der das Level erstellt hat */
userId: string;
/** Zeitpunkt der Erstellung des Levels */
createdAt: string;
/** Zeitpunkt der letzten Aktualisierung des Levels */
updatedAt: string;
/** Gibt an, ob das Level öffentlich ist */
isPublic?: boolean;
/** Anzahl der Aufrufe des Levels */
playCount: number;
/** Anzahl der Likes des Levels */
likesCount: number;
/** Schwierigkeitsgrad des Levels */
difficulty?: string;
/** Tags zur Kategorisierung des Levels */
tags?: string[];
/** URL zum Vorschaubild des Levels */
thumbnailUrl?: string;
}
/**
* Repräsentiert ein vollständiges Level mit allen Daten
*/
export interface Level extends LevelMetadata {
/** Liste aller Blöcke im Level */
blocks: Block[];
/** Position des Spawn-Punkts */
spawnPoint: SpawnPoint;
/** Größe der Spielwelt */
worldSize: WorldSize;
}

View file

@ -0,0 +1,200 @@
<script lang="ts">
import type { NodeKind, ContentData } from '$lib/types/content';
interface Props {
kind: NodeKind;
onGenerated: (data: {
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}) => void;
context?: {
world?: string;
existingCharacters?: string[];
existingPlaces?: string[];
existingObjects?: string[];
};
}
let { kind, onGenerated, context }: Props = $props();
let isOpen = $state(false);
let prompt = $state('');
let generating = $state(false);
let error = $state<string | null>(null);
const kindLabels: Record<NodeKind, string> = {
character: 'Charakter',
world: 'Welt',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
const placeholders: Record<NodeKind, string> = {
character: 'Ein weiser alter Magier mit einem Geheimnis...',
world: 'Eine düstere Cyberpunk-Welt mit magischen Elementen...',
place: 'Ein mysteriöser Wald, in dem die Zeit anders verläuft...',
object: 'Ein Amulett, das seinem Träger besondere Kräfte verleiht...',
story: 'Eine Heldenreise, bei der ungleiche Gefährten zusammenfinden...',
};
async function generate() {
if (!prompt.trim()) return;
generating = true;
error = null;
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
prompt,
context,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Generierung fehlgeschlagen');
}
const result = await response.json();
onGenerated(result);
isOpen = false;
prompt = '';
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
generating = false;
}
}
function toggleDialog() {
isOpen = !isOpen;
if (!isOpen) {
prompt = '';
error = null;
}
}
</script>
<div class="relative">
<button
type="button"
onclick={toggleDialog}
class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium leading-4 text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
KI-Generierung
</button>
{#if isOpen}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div
class="flex min-h-screen items-center justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0"
>
<!-- Background overlay -->
<div
class="fixed inset-0 bg-slate-500 bg-opacity-75 transition-opacity"
onclick={toggleDialog}
></div>
<!-- Modal panel -->
<div
class="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
>
<div>
<div
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-violet-100"
>
<svg
class="h-6 w-6 text-violet-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-lg font-medium leading-6 text-slate-900">
{kindLabels[kind]} mit KI generieren
</h3>
<div class="mt-2">
<p class="text-sm text-slate-500">
Beschreibe, was du erstellen möchtest. Die KI generiert dann alle Details für
dich.
</p>
</div>
</div>
</div>
<div class="mt-5">
{#if error}
<div class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<textarea
bind:value={prompt}
disabled={generating}
rows="4"
placeholder={placeholders[kind]}
class="w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50 sm:text-sm"
></textarea>
{#if context}
<div class="mt-2 text-xs text-slate-500">
{#if context.world}
<p>Welt: {context.world}</p>
{/if}
{#if context.existingCharacters?.length}
<p>Verfügbare Charaktere: {context.existingCharacters.slice(0, 3).join(', ')}</p>
{/if}
{#if context.existingPlaces?.length}
<p>Verfügbare Orte: {context.existingPlaces.slice(0, 3).join(', ')}</p>
{/if}
</div>
{/if}
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
onclick={generate}
disabled={generating || !prompt.trim()}
class="inline-flex w-full justify-center rounded-md border border-transparent bg-violet-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-2 sm:text-sm"
>
{generating ? 'Generiere...' : 'Generieren'}
</button>
<button
type="button"
onclick={toggleDialog}
disabled={generating}
class="mt-3 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-1 sm:mt-0 sm:text-sm"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface Props {
kind?: NodeKind;
title?: string;
description?: string;
appearance?: string;
prompt?: string;
imagePrompt?: string;
imageUrl?: string | null;
onImageGenerated?: (imageUrl: string) => void;
}
let {
kind = 'character',
title = '',
description = '',
appearance = '',
prompt = $bindable(''),
imagePrompt = $bindable(''),
imageUrl = $bindable(null),
onImageGenerated,
}: Props = $props();
let loading = $state(false);
let translating = $state(false);
let error = $state<string | null>(null);
let generatedImageUrl = $state<string | null>(null);
let selectedStyle = $state<'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration'>(
'fantasy'
);
let showOptions = $state(false);
// Extract title and description from prompt if provided
$effect(() => {
if (prompt) {
const parts = prompt.split(':');
if (parts.length > 0 && !title) {
title = parts[0].trim();
}
if (parts.length > 1 && !description && !appearance) {
description = parts.slice(1).join(':').trim();
}
}
});
// Determine aspect ratio based on kind
function getAspectRatio() {
switch (kind) {
case 'world':
case 'place':
return '16:9'; // Widescreen for worlds and places
case 'object':
return '1:1'; // Square for objects
case 'character':
return '9:16'; // Portrait for characters
default:
return '1:1'; // Default to square
}
}
// Get CSS class for image display based on aspect ratio
function getImageClass() {
const aspectRatio = getAspectRatio();
switch (aspectRatio) {
case '21:9':
return 'w-full aspect-[21/9]'; // 21:9 ultrawide aspect ratio
case '16:9':
return 'w-full aspect-video'; // 16:9 aspect ratio
case '9:16':
return 'w-64 mx-auto aspect-[9/16]'; // 9:16 aspect ratio, centered
case '1:1':
default:
return 'w-full max-w-md mx-auto aspect-square'; // 1:1 aspect ratio
}
}
async function translateToEnglish() {
const germanText = appearance || description;
if (!germanText || germanText.length < 10) {
error = 'Keine deutsche Beschreibung zum Übersetzen vorhanden';
return;
}
translating = true;
error = null;
try {
const response = await fetch('/api/ai/translate-image-prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
germanDescription: germanText,
kind,
title: title || 'Unbenannt',
style: selectedStyle,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Übersetzung fehlgeschlagen');
}
const data = await response.json();
if (data.englishPrompt) {
imagePrompt = data.englishPrompt;
}
} catch (err) {
console.error('Translation error:', err);
error = err instanceof Error ? err.message : 'Übersetzung fehlgeschlagen';
} finally {
translating = false;
}
}
async function generateImage() {
const effectiveTitle = title || prompt?.split(':')[0]?.trim();
const effectiveDescription =
description || appearance || prompt?.split(':').slice(1).join(':')?.trim();
if (!effectiveTitle) {
error = 'Titel ist erforderlich für die Bildgenerierung';
return;
}
loading = true;
error = null;
try {
const response = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
kind,
title: effectiveTitle,
description: imagePrompt || effectiveDescription,
style: selectedStyle,
aspectRatio: getAspectRatio(),
context: {
appearance: imagePrompt || appearance || effectiveDescription,
},
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Bildgenerierung fehlgeschlagen');
}
const data = await response.json();
console.log('Response von API:', data); // Debug-Log
if (data.imageUrl) {
generatedImageUrl = data.imageUrl;
imageUrl = data.imageUrl; // Update the bound prop
onImageGenerated?.(data.imageUrl);
console.log('Bild-URL gesetzt:', generatedImageUrl); // Debug-Log
}
imagePrompt = data.prompt;
prompt = data.prompt; // Update the bound prompt prop
// Zeige Info-Message wenn Bild noch nicht verfügbar
if (!data.imageUrl && data.message) {
error = data.message;
console.log('Kein Bild, Nachricht:', data.message); // Debug-Log
}
} catch (err) {
console.error('Fehler:', err);
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function resetImage() {
generatedImageUrl = null;
imagePrompt = null;
error = null;
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-slate-900">Bild generieren</h3>
{#if !generatedImageUrl}
<button
type="button"
onclick={() => (showOptions = !showOptions)}
class="text-sm text-violet-600 hover:text-violet-500"
>
{showOptions ? 'Optionen ausblenden' : 'Optionen anzeigen'}
</button>
{/if}
</div>
{#if showOptions && !generatedImageUrl}
<div class="space-y-3 rounded-md bg-slate-50 p-3">
<div>
<label for="style" class="block text-sm font-medium text-slate-700"> Bildstil </label>
<select
id="style"
bind:value={selectedStyle}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
>
<option value="fantasy">Fantasy</option>
<option value="realistic">Realistisch</option>
<option value="anime">Anime</option>
<option value="concept-art">Concept Art</option>
<option value="illustration">Illustration</option>
</select>
</div>
<p class="text-xs text-slate-500">
Das Bild wird basierend auf dem Titel und der Beschreibung generiert.
</p>
</div>
{/if}
<!-- Deutsche Beschreibung und Übersetzung -->
{#if appearance && !generatedImageUrl}
<div class="space-y-3 rounded-md border border-blue-200 bg-blue-50/50 p-3">
<div>
<h4 class="mb-2 text-sm font-medium text-slate-700">Deutsche Beschreibung:</h4>
<p class="rounded border bg-white p-2 text-sm text-slate-600">{appearance}</p>
</div>
{#if !imagePrompt}
<button
type="button"
onclick={translateToEnglish}
disabled={translating}
class="flex w-full items-center justify-center rounded-md border border-blue-300 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 shadow-sm hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if translating}
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Übersetze...
{:else}
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
Ins Englische übersetzen
{/if}
</button>
{:else}
<div>
<h4 class="mb-2 flex items-center text-sm font-medium text-green-700">
<svg
class="mr-2 h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Englischer Bild-Prompt:
</h4>
<p class="rounded border border-green-200 bg-green-50 p-2 text-sm text-slate-600">
{imagePrompt}
</p>
</div>
{/if}
</div>
{/if}
{#if generatedImageUrl}
<div class="relative">
<img
src={generatedImageUrl}
alt={`Generiertes Bild für ${title}`}
class="{getImageClass()} rounded-lg object-cover shadow-md"
/>
<button
type="button"
onclick={resetImage}
class="bg-theme-surface/90 absolute right-2 top-2 rounded-full p-2 shadow-lg backdrop-blur-sm transition-all hover:bg-theme-surface"
title="Neues Bild generieren"
>
<svg
class="h-5 w-5 text-theme-text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{:else}
<button
type="button"
onclick={generateImage}
disabled={loading || (!title && !prompt) || (appearance && !imagePrompt)}
class="border-theme-border-default flex w-full items-center justify-center rounded-md border bg-theme-surface px-4 py-3 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<svg
class="-ml-1 mr-3 h-5 w-5 animate-spin text-theme-text-primary"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Generiere Bild...
{:else if appearance && !imagePrompt}
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
Bitte zuerst deutsche Beschreibung übersetzen
{:else}
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Bild mit KI generieren
{/if}
</button>
{/if}
{#if error}
<div class="rounded-md bg-yellow-50/50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-theme-warning">{error}</p>
</div>
</div>
</div>
{/if}
{#if imagePrompt}
<details class="text-xs text-theme-text-secondary">
<summary class="cursor-pointer hover:text-theme-text-primary">Verwendeter Prompt</summary>
<p class="mt-2 rounded bg-theme-elevated p-2 font-mono text-xs text-theme-text-secondary">
{imagePrompt}
</p>
</details>
{/if}
</div>

View file

@ -0,0 +1,607 @@
<script lang="ts">
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
import AiImageGenerator from './AiImageGenerator.svelte';
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import type { NodeKind } from '$lib/types/content';
let command = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state<string | null>(null);
let processingCommands = $state<Set<string>>(new Set());
// Image generation state
let imagePrompt = $state('');
let imageUrl = $state<string | null>(null);
let generatedPrompt = $state<string | null>(null);
// Subscribe to store
let aiState = $state({
isVisible: false,
currentNode: null as any,
isOwner: false,
mode: 'text' as 'text' | 'image',
imageGenerationState: {
loading: false,
generatedUrl: null as string | null,
prompt: '',
style: 'fantasy' as any,
error: null as string | null,
},
});
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = aiAuthorStore.subscribe((state) => {
console.log('🌟 GlobalAiAuthorBar: Store update', state);
aiState = state;
// Auto-populate image prompt from node appearance
if (state.mode === 'image' && state.currentNode && !imagePrompt) {
const node = state.currentNode;
imagePrompt = node.content?.appearance || node.summary || '';
}
});
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
// Auto-hide success/error messages
let successTimeout: ReturnType<typeof setTimeout>;
let errorTimeout: ReturnType<typeof setTimeout>;
function showSuccess(message: string) {
success = message;
clearTimeout(successTimeout);
successTimeout = setTimeout(() => {
success = null;
}, 4000);
}
function showError(message: string) {
error = message;
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
error = null;
}, 6000);
}
async function executeCommand() {
const currentCommand = command.trim();
if (!currentCommand || processingCommands.has(currentCommand) || !aiState.currentNode) return;
// Add to processing queue
processingCommands.add(currentCommand);
processingCommands = new Set(processingCommands);
// Clear input immediately for better UX
command = '';
loading = true;
error = null;
// Show processing feedback
showSuccess(
`🔄 Bearbeite: "${currentCommand.substring(0, 50)}${currentCommand.length > 50 ? '...' : ''}"`
);
try {
const response = await fetch('/api/ai/edit-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nodeSlug: aiState.currentNode.slug,
command: currentCommand,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Bearbeiten');
}
if (data.success && data.updatedNode) {
aiAuthorStore.updateNode(data.updatedNode);
showSuccess(`✅ Erfolgreich bearbeitet: "${currentCommand.substring(0, 30)}..."`);
// Dispatch custom event to notify components
window.dispatchEvent(
new CustomEvent('node-updated', {
detail: { updatedNode: data.updatedNode },
})
);
} else {
throw new Error('Unexpected response format');
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Ein unerwarteter Fehler ist aufgetreten';
showError(`❌ Fehler: ${errorMessage}`);
} finally {
// Remove from processing queue
processingCommands.delete(currentCommand);
processingCommands = new Set(processingCommands);
loading = processingCommands.size > 0;
}
}
async function handleImageGenerated(url: string) {
imageUrl = url;
aiAuthorStore.setImageState({ generatedUrl: url });
await saveGeneratedImage();
}
async function saveGeneratedImage() {
if (!imageUrl || !aiState.currentNode) return;
loading = true;
error = null;
try {
// Use the proper attachments-based endpoint to save image
const response = await fetch(`/api/nodes/${aiState.currentNode.slug}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
prompt: generatedPrompt || imagePrompt,
is_primary: false,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
}
showSuccess('🖼️ Bild erfolgreich gespeichert!');
// Reset image state
imageUrl = null;
generatedPrompt = null;
aiAuthorStore.resetImageState();
// Notify components to reload images
window.dispatchEvent(
new CustomEvent('images-updated', {
detail: { nodeSlug: aiState.currentNode.slug },
})
);
} catch (err) {
showError(err instanceof Error ? err.message : 'Fehler beim Speichern');
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
// Only handle global shortcuts when author bar is focused
if (e.target && (e.target as HTMLElement).closest('#global-ai-author-bar')) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (aiState.mode === 'text') {
executeCommand();
}
}
}
// Global escape to close
if (e.key === 'Escape' && aiState.isVisible) {
aiAuthorStore.hide();
}
}
function toggleVisibility() {
aiAuthorStore.toggle();
if (aiState.isVisible) {
// Focus the textarea when shown
setTimeout(() => {
if (aiState.mode === 'text') {
const textarea = document.querySelector(
'#global-ai-command-input'
) as HTMLTextAreaElement;
textarea?.focus();
}
}, 100);
}
}
function switchMode(mode: 'text' | 'image') {
aiAuthorStore.setMode(mode);
error = null;
success = null;
}
// Command suggestions based on node type
function getSuggestions() {
if (!aiState.currentNode) return [];
const suggestions = {
character: [
'Benenne um zu Maximilian der Große',
'Füge zur Erscheinung hinzu: trägt einen roten Mantel',
'Ändere die Fähigkeiten zu: Meister der Feuermagie',
'Aktualisiere das Inventar: trägt @magisches-schwert',
],
place: [
'Benenne um zu Die goldene Stadt',
'Füge zur Geschichte hinzu: wurde vor 100 Jahren erbaut',
'Ändere die Gefahren zu: wilde Kreaturen in der Nacht',
'Aktualisiere den Zustand: jetzt in Ruinen',
],
object: [
'Benenne um zu Schwert der Macht',
'Füge zu den Fähigkeiten hinzu: kann Feinde blenden',
'Ändere den Besitzer zu: gehört jetzt @aragorn',
'Aktualisiere die Erscheinung: glänzt in blauem Licht',
],
world: [
'Benenne um zu Reich der tausend Sonnen',
'Füge zur Geschichte hinzu: geprägt von magischen Kriegen',
'Aktualisiere die Regeln: Magie ist verboten',
'Ändere die Zeitlinie: Das große Erwachen im Jahr 2157',
],
story: [
'Benenne um zu Das letzte Abenteuer',
'Füge zum Plot hinzu: die Helden treffen auf einen Drachen',
'Ändere die Referenzen zu: @mira, @dunkler-turm, @zauberring',
'Aktualisiere den Verlauf: endet mit einem Cliffhanger',
],
};
return suggestions[aiState.currentNode.kind as NodeKind] || [];
}
let suggestions = $derived(getSuggestions());
</script>
<!-- Floating Toast Notifications -->
<div class="fixed right-4 top-20 z-50 space-y-2">
{#if success}
<div
transition:fly={{ x: 100, duration: 300 }}
class="max-w-sm rounded-lg border border-theme-border-subtle bg-theme-surface shadow-lg"
>
<div class="flex items-start p-4">
<div class="flex-shrink-0">
{#if success.includes('🔄')}
<svg
class="h-5 w-5 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5 text-theme-success" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
<p class="ml-3 text-sm text-theme-text-primary">{success}</p>
</div>
</div>
{/if}
{#if error}
<div
transition:fly={{ x: 100, duration: 300 }}
class="max-w-sm rounded-lg border border-theme-error/20 bg-theme-error/10 shadow-lg"
>
<div class="flex items-start p-4">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-theme-error" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<p class="ml-3 text-sm text-theme-error">{error}</p>
</div>
</div>
{/if}
</div>
<!-- Global Floating Toggle Button -->
{#if aiState.currentNode && aiState.isOwner}
<button
onclick={toggleVisibility}
class="fixed bottom-4 right-4 z-40 rounded-full bg-gradient-to-br from-theme-primary-500 to-theme-primary-600 p-3 text-white shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-110 {aiState.isVisible
? 'rotate-45'
: ''} {loading ? 'animate-pulse' : ''}"
title="AI Author Bar {aiState.isVisible ? 'schließen' : 'öffnen'}"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{#if loading}
<div class="absolute -right-1 -top-1 h-3 w-3">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-theme-primary-400 opacity-75"
></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-theme-primary-500"></span>
</div>
{/if}
</button>
{/if}
<!-- Global Author Bar -->
{#if aiState.currentNode && aiState.isOwner}
<div
id="global-ai-author-bar"
class="fixed inset-x-0 bottom-0 z-50 border-t border-theme-border-default bg-theme-surface/95 backdrop-blur-md shadow-2xl transition-transform duration-300 {aiState.isVisible
? 'translate-y-0'
: 'translate-y-full'}"
>
<div class="mx-auto max-w-4xl p-4">
<!-- Header with Tabs -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<div class="relative">
<div
class="h-3 w-3 rounded-full {loading
? 'bg-theme-primary-500 animate-pulse'
: 'bg-theme-success'}"
></div>
{#if processingCommands.size > 0}
<div
class="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-theme-primary-600 text-[10px] text-white"
>
{processingCommands.size}
</div>
{/if}
</div>
<h3 class="text-base font-medium text-theme-text-primary">✨ AI Author</h3>
</div>
<!-- Tab Navigation -->
<div class="flex rounded-lg bg-theme-elevated p-0.5">
<button
onclick={() => switchMode('text')}
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
'text'
? 'bg-theme-surface text-theme-text-primary shadow-sm'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>Text</span>
</button>
<button
onclick={() => switchMode('image')}
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
'image'
? 'bg-theme-surface text-theme-text-primary shadow-sm'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Bild</span>
</button>
</div>
</div>
<button
onclick={() => aiAuthorStore.hide()}
class="p-1 text-theme-text-secondary transition-colors hover:text-theme-text-primary"
title="Schließen (Esc)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content Area -->
{#if aiState.mode === 'text'}
<!-- Text Edit Mode -->
<div class="space-y-3">
<!-- Command Input -->
<div class="relative">
<textarea
id="global-ai-command-input"
bind:value={command}
onkeydown={handleKeydown}
placeholder="z.B. 'Benenne um zu Maximilian der Große' oder 'Füge zur Erscheinung hinzu: trägt eine goldene Krone'"
rows="2"
class="w-full resize-none rounded-md border border-theme-border-default bg-theme-background pr-20 text-sm shadow-sm transition-all focus:border-theme-primary-500 focus:ring-2 focus:ring-theme-primary-500/20 {loading
? 'pl-10'
: ''}"
></textarea>
{#if loading}
<div class="absolute left-3 top-3">
<svg
class="h-4 w-4 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
{/if}
<div class="absolute bottom-1 right-2 text-xs text-theme-text-secondary">⌘+Enter</div>
</div>
<!-- Quick Suggestions -->
{#if suggestions.length > 0 && !command.trim()}
<div class="scrollbar-thin flex gap-2 overflow-x-auto pb-1">
{#each suggestions as suggestion}
<button
onclick={() => (command = suggestion)}
class="flex-shrink-0 whitespace-nowrap rounded-full border border-theme-border-default bg-theme-elevated px-3 py-1 text-xs transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
{suggestion}
</button>
{/each}
</div>
{/if}
<!-- Processing Queue Display -->
{#if processingCommands.size > 0}
<div class="rounded-lg bg-theme-primary-500/10 p-2">
<p class="mb-1 text-xs font-medium text-theme-text-secondary">
Verarbeite {processingCommands.size} Befehl{processingCommands.size !== 1
? 'e'
: ''}:
</p>
<div class="space-y-1">
{#each Array.from(processingCommands) as cmd}
<div class="flex items-center space-x-2 text-xs text-theme-text-secondary">
<svg
class="h-3 w-3 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="truncate"
>{cmd.substring(0, 50)}{cmd.length > 50 ? '...' : ''}</span
>
</div>
{/each}
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex items-center justify-between">
<div class="text-xs text-theme-text-secondary">
<span class="inline-flex items-center">
<span
class="mr-1 h-2 w-2 rounded-full {loading
? 'animate-pulse bg-theme-primary-500'
: 'bg-theme-success'}"
></span>
{loading
? `Verarbeite ${processingCommands.size} Befehl${processingCommands.size !== 1 ? 'e' : ''}...`
: 'AI bereit'}
</span>
</div>
<div class="flex space-x-2">
<button
onclick={() => aiAuthorStore.hide()}
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
Schließen
</button>
<button
onclick={executeCommand}
disabled={!command.trim()}
class="flex items-center space-x-2 rounded bg-gradient-to-r from-theme-primary-500 to-theme-primary-600 px-4 py-1.5 text-sm text-white transition-all hover:from-theme-primary-600 hover:to-theme-primary-700 hover:shadow-lg disabled:opacity-50"
>
<span>✨ Mit AI bearbeiten</span>
{#if loading}
<span class="text-xs opacity-75">({processingCommands.size})</span>
{/if}
</button>
</div>
</div>
</div>
{:else}
<!-- Image Generation Mode -->
<div class="space-y-4">
{#if aiState.currentNode}
<AiImageGenerator
kind={aiState.currentNode.kind}
title={aiState.currentNode.title}
description={aiState.currentNode.summary}
appearance={aiState.currentNode.content?.appearance}
bind:imageUrl
bind:prompt={generatedPrompt}
onImageGenerated={handleImageGenerated}
/>
{/if}
{#if imageUrl}
<div class="flex justify-end space-x-2 border-t border-theme-border-subtle pt-3">
<button
onclick={() => {
imageUrl = null;
generatedPrompt = null;
aiAuthorStore.resetImageState();
}}
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-interactive-hover"
>
Verwerfen
</button>
<button
onclick={saveGeneratedImage}
disabled={loading}
class="rounded bg-theme-primary-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Zur Galerie hinzufügen'}
</button>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<svelte:window onkeydown={handleKeydown} />

View file

@ -0,0 +1,222 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface ImageItem {
id: string;
image_url: string;
prompt?: string;
is_primary: boolean;
sort_order: number;
created_at: string;
}
interface Props {
images: ImageItem[];
nodeSlug: string;
nodeKind: NodeKind;
editable?: boolean;
onImageUpdate?: () => void;
}
let { images = [], nodeSlug, nodeKind, editable = false, onImageUpdate }: Props = $props();
let selectedImage = $state<ImageItem | null>(null);
let showLightbox = $state(false);
let loading = $state(false);
// Sort images: primary first, then by sort_order
let sortedImages = $derived(
[...images].sort((a, b) => {
if (a.is_primary && !b.is_primary) return -1;
if (!a.is_primary && b.is_primary) return 1;
return a.sort_order - b.sort_order;
})
);
let primaryImage = $derived(sortedImages.find((img) => img.is_primary) || sortedImages[0]);
let galleryImages = $derived(sortedImages.filter((img) => !img.is_primary));
function openLightbox(image: ImageItem) {
selectedImage = image;
showLightbox = true;
}
function closeLightbox() {
showLightbox = false;
selectedImage = null;
}
async function setPrimaryImage(imageId: string) {
if (!editable || loading) return;
loading = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true }),
});
if (response.ok) {
onImageUpdate?.();
} else {
console.error('Failed to set primary image');
}
} catch (error) {
console.error('Error setting primary image:', error);
} finally {
loading = false;
}
}
async function deleteImage(imageId: string) {
if (!editable || loading) return;
loading = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
method: 'DELETE',
});
if (response.ok) {
onImageUpdate?.();
} else {
console.error('Failed to delete image');
}
} catch (error) {
console.error('Error deleting image:', error);
} finally {
loading = false;
}
}
// Get aspect ratio class based on node kind for primary image display
function getAspectClass() {
switch (nodeKind) {
case 'world':
case 'place':
return 'w-full aspect-[21/9]'; // 21:9 ultrawide
case 'character':
return 'w-full aspect-[9/16]'; // Portrait 9:16 format
case 'object':
default:
return 'w-full aspect-square'; // 1:1
}
}
</script>
{#if images.length > 0}
<!-- Primary Image Display -->
{#if primaryImage}
<div class="mb-6">
<div class="group relative">
<button onclick={() => openLightbox(primaryImage)} class="block w-full">
<img
src={primaryImage.image_url}
alt="Hauptbild"
class={`${getAspectClass()} rounded-lg object-cover shadow-lg transition-shadow hover:shadow-xl`}
onload={() => console.log('🖼️ Primary image loaded:', primaryImage.image_url)}
onerror={(e) =>
console.error('🚨 Primary image failed to load:', primaryImage.image_url, e)}
/>
</button>
</div>
</div>
{/if}
<!-- Gallery Grid -->
{#if galleryImages.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{#each galleryImages as image}
<div class="group relative">
<button onclick={() => openLightbox(image)} class="block w-full">
<img
src={image.image_url}
alt="Galeriebild"
class="aspect-square w-full rounded-lg object-cover shadow transition-shadow hover:shadow-lg"
onload={() => console.log('🖼️ Gallery image loaded:', image.image_url)}
onerror={(e) => console.error('🚨 Gallery image failed to load:', image.image_url, e)}
/>
</button>
{#if editable}
<div
class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => setPrimaryImage(image.id)}
disabled={loading}
class="rounded-full bg-white p-1 shadow-md hover:bg-yellow-50 disabled:opacity-50"
title="Als Hauptbild setzen"
>
<svg class="h-4 w-4 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</button>
<button
onclick={() => deleteImage(image.id)}
disabled={loading}
class="hover:bg-theme-error/10 rounded-full bg-theme-surface p-1 shadow-md disabled:opacity-50"
title="Löschen"
>
<svg
class="h-4 w-4 text-theme-error"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<div class="py-8 text-center text-gray-500">Noch keine Bilder vorhanden</div>
{/if}
<!-- Lightbox -->
{#if showLightbox && selectedImage}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
onclick={closeLightbox}
>
<div class="relative max-h-full max-w-6xl">
<img
src={selectedImage.image_url}
alt="Vollbild"
class="max-h-[90vh] max-w-full object-contain"
onclick={(e) => e.stopPropagation()}
/>
<button onclick={closeLightbox} class="absolute right-4 top-4 text-white hover:text-gray-300">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if selectedImage.prompt}
<div
class="absolute bottom-4 left-4 right-4 mx-auto max-w-2xl rounded-lg bg-black bg-opacity-75 p-4 text-white"
>
<p class="text-sm">{selectedImage.prompt}</p>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,319 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
show: boolean;
nodeSlug: string;
onClose: () => void;
onUploadComplete: () => void;
}
let { show, nodeSlug, onClose, onUploadComplete }: Props = $props();
let dragActive = $state(false);
let selectedFiles = $state<File[]>([]);
let uploadProgress = $state<number>(0);
let uploading = $state(false);
let fileInput: HTMLInputElement;
let previews = $state<{ file: File; url: string }[]>([]);
// Max file size: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragActive = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
// Only set dragActive to false if we're leaving the drop zone entirely
const target = e.target as HTMLElement;
const relatedTarget = e.relatedTarget as HTMLElement;
if (!target.closest('.drop-zone') || !relatedTarget?.closest('.drop-zone')) {
dragActive = false;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragActive = false;
const files = Array.from(e.dataTransfer?.files || []);
processFiles(files);
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const files = Array.from(target.files || []);
processFiles(files);
}
function processFiles(files: File[]) {
const validFiles = files.filter((file) => {
if (!ALLOWED_TYPES.includes(file.type)) {
alert(`${file.name} ist kein unterstütztes Bildformat`);
return false;
}
if (file.size > MAX_FILE_SIZE) {
alert(`${file.name} ist zu groß (max. 10MB)`);
return false;
}
return true;
});
selectedFiles = [...selectedFiles, ...validFiles];
// Create preview URLs
validFiles.forEach((file) => {
const url = URL.createObjectURL(file);
previews = [...previews, { file, url }];
});
}
function removeFile(index: number) {
// Revoke the URL to free memory
URL.revokeObjectURL(previews[index].url);
selectedFiles = selectedFiles.filter((_, i) => i !== index);
previews = previews.filter((_, i) => i !== index);
}
async function uploadFiles() {
if (selectedFiles.length === 0) return;
uploading = true;
// Create upload steps based on number of files
const steps = selectedFiles.map(
(file, i) => `Lade Bild ${i + 1}/${selectedFiles.length}: ${file.name}`
);
loadingStore.start('Bilder werden hochgeladen', steps);
uploadProgress = 0;
try {
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const formData = new FormData();
formData.append('image', file);
// Set first image as primary if no images exist yet
formData.append('is_primary', i === 0 ? 'true' : 'false');
const response = await fetch(`/api/nodes/${nodeSlug}/images/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Upload fehlgeschlagen: ${error}`);
}
uploadProgress = ((i + 1) / selectedFiles.length) * 100;
loadingStore.nextStep(`Bild ${i + 1} erfolgreich hochgeladen`);
}
// Clean up preview URLs
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
// Reset state
selectedFiles = [];
previews = [];
uploadProgress = 0;
// Mark loading as complete
loadingStore.complete('Alle Bilder erfolgreich hochgeladen');
// Notify parent
onUploadComplete();
onClose();
} catch (error) {
console.error('Upload error:', error);
loadingStore.setError(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
alert(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
// Reset loading after error
setTimeout(() => loadingStore.reset(), 2000);
} finally {
uploading = false;
}
}
function openFileDialog() {
fileInput?.click();
}
// Clean up URLs when component is destroyed
$effect(() => {
return () => {
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
};
});
</script>
{#if show}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 200 }}
onclick={onClose}
>
<div
class="relative w-full max-w-3xl rounded-lg bg-theme-surface p-6 shadow-xl"
transition:scale={{ duration: 200, start: 0.95 }}
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-theme-text-primary">Bilder hochladen</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Drop Zone -->
<div
class="drop-zone mb-6 rounded-lg border-2 border-dashed p-8 text-center transition-colors
{dragActive
? 'border-theme-primary-600 bg-theme-primary-100/10'
: 'border-theme-border-subtle hover:border-theme-border-default'}"
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}
>
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-text-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="mb-2 text-lg text-theme-text-primary">
Bilder hier ablegen oder
<button onclick={openFileDialog} class="text-theme-primary-600 hover:underline">
durchsuchen
</button>
</p>
<p class="text-sm text-theme-text-secondary">
JPG, PNG, WebP oder GIF • Max. 10MB pro Bild
</p>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
onchange={handleFileSelect}
class="hidden"
/>
</div>
<!-- Preview Grid -->
{#if previews.length > 0}
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-theme-text-primary">
Ausgewählte Bilder ({previews.length})
</h3>
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{#each previews as preview, index}
<div class="group relative">
<img
src={preview.url}
alt="Vorschau"
class="aspect-square w-full rounded-lg object-cover"
/>
<button
onclick={() => removeFile(index)}
class="absolute right-1 top-1 rounded-full bg-red-600 p-1 opacity-0 transition-opacity group-hover:opacity-100"
title="Entfernen"
>
<svg
class="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if index === 0}
<span
class="absolute bottom-1 left-1 rounded bg-yellow-500 px-1.5 py-0.5 text-xs font-semibold text-white"
>
Hauptbild
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Upload Progress -->
{#if uploading}
<div class="mb-6">
<div class="mb-1 flex justify-between text-sm">
<span class="text-theme-text-secondary">Hochladen...</span>
<span class="text-theme-text-primary">{Math.round(uploadProgress)}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-theme-elevated">
<div
class="h-full bg-theme-primary-600 transition-all duration-300"
style="width: {uploadProgress}%"
/>
</div>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-end gap-3">
<button
onclick={onClose}
disabled={uploading}
class="rounded-lg px-4 py-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary disabled:opacity-50"
>
Abbrechen
</button>
<button
onclick={uploadFiles}
disabled={selectedFiles.length === 0 || uploading}
class="rounded-lg bg-theme-primary-600 px-4 py-2 text-white hover:bg-theme-primary-700 disabled:opacity-50"
>
{uploading
? 'Wird hochgeladen...'
: `${selectedFiles.length} Bild${selectedFiles.length !== 1 ? 'er' : ''} hochladen`}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,141 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
import AiImageGenerator from './AiImageGenerator.svelte';
interface Props {
nodeSlug: string;
nodeKind: NodeKind;
nodeTitle: string;
nodeDescription?: string;
onImageAdded?: () => void;
}
let { nodeSlug, nodeKind, nodeTitle, nodeDescription, onImageAdded }: Props = $props();
let showGenerator = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let imageUrl = $state<string | null>(null);
let generationPrompt = $state<string | null>(null);
async function handleImageGenerated(url: string) {
imageUrl = url;
await saveImage();
}
async function saveImage() {
if (!imageUrl) return;
loading = true;
error = null;
try {
// Use the proper attachments-based endpoint
const response = await fetch(`/api/nodes/${nodeSlug}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
prompt: generationPrompt || `${nodeTitle}: ${nodeDescription || ''}`,
is_primary: false, // New images are not primary by default
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
}
// Reset and close
imageUrl = null;
generationPrompt = null;
showGenerator = false;
onImageAdded?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function toggleGenerator() {
showGenerator = !showGenerator;
if (!showGenerator) {
// Reset state when closing
imageUrl = null;
generationPrompt = null;
error = null;
}
}
</script>
<div class="space-y-4">
{#if !showGenerator}
<button
onclick={toggleGenerator}
class="border-theme-border-default flex w-full items-center justify-center rounded-lg border-2 border-dashed px-4 py-3 transition-colors hover:border-theme-primary-400 hover:bg-theme-primary-50"
>
<svg
class="mr-2 h-6 w-6 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="font-medium text-theme-text-secondary">Neues Bild generieren</span>
</button>
{:else}
<div class="rounded-lg border border-theme-border-subtle bg-theme-surface p-6 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-theme-text-primary">Neues Bild generieren</h3>
<button
onclick={toggleGenerator}
class="text-theme-text-tertiary hover:text-theme-text-primary"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50/50 p-3">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<AiImageGenerator
kind={nodeKind}
title={nodeTitle}
description={nodeDescription}
bind:imageUrl
bind:prompt={generationPrompt}
onImageGenerated={handleImageGenerated}
/>
{#if imageUrl}
<div class="mt-4 flex justify-end space-x-3">
<button
onclick={toggleGenerator}
class="border-theme-border-default rounded-md border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
Abbrechen
</button>
<button
onclick={saveImage}
disabled={loading}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-theme-inverse hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Bild zur Galerie hinzufügen'}
</button>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,852 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
import { goto } from '$app/navigation';
import PromptInfo from './PromptInfo.svelte';
import ImageGallery from './ImageGallery.svelte';
import { extractMentions } from '$lib/utils/mentions';
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
import { onMount, onDestroy } from 'svelte';
import { renderMarkdown, parseReferences as parseRefs } from '$lib/utils/markdown';
import NodeMemory from './NodeMemory.svelte';
import SmartMarkdown from './SmartMarkdown.svelte';
import CustomFieldsDisplay from './customFields/CustomFieldsDisplay.svelte';
import ImageUploadModal from './ImageUploadModal.svelte';
interface Props {
node: ContentNode;
isOwner: boolean;
onDelete: () => void;
editPath?: string;
backPath?: string;
}
let { node: initialNode, isOwner, onDelete, editPath, backPath }: Props = $props();
// Make node reactive for AI updates
let node = $state(initialNode);
// Update node when initialNode changes (e.g., navigation)
$effect(() => {
node = initialNode;
// Update global AI Author Bar context when node changes
if (isOwner) {
aiAuthorStore.setContext(node, isOwner);
}
});
// Set AI Author Bar context on mount
onMount(() => {
console.log('🎯 NodeDetail: Setting AI context', { node: node.slug, isOwner });
if (isOwner) {
aiAuthorStore.setContext(node, isOwner);
}
});
// Listen for node updates from AI Author Bar
let handleNodeUpdate: (event: CustomEvent) => void;
let handleImagesUpdate: (event: CustomEvent) => void;
onMount(() => {
handleNodeUpdate = (event: CustomEvent) => {
if (event.detail.updatedNode.slug === node.slug) {
node = event.detail.updatedNode;
// Re-load linked objects if this is a character
if (node.kind === 'character') {
loadLinkedObjects();
}
}
};
handleImagesUpdate = (event: CustomEvent) => {
if (event.detail.nodeSlug === node.slug) {
console.log('📸 Images updated event received, reloading...');
loadImages();
}
};
window.addEventListener('node-updated', handleNodeUpdate as EventListener);
window.addEventListener('images-updated', handleImagesUpdate as EventListener);
});
onDestroy(() => {
if (handleNodeUpdate) {
window.removeEventListener('node-updated', handleNodeUpdate as EventListener);
}
if (handleImagesUpdate) {
window.removeEventListener('images-updated', handleImagesUpdate as EventListener);
}
});
// State for linked objects
let linkedObjects = $state<ContentNode[]>([]);
let loadingObjects = $state(false);
// State for image gallery
let images = $state<any[]>([]);
let loadingImages = $state(false);
// State for tabs
let activeTab = $state<'info' | 'memory' | 'prompt' | 'custom'>('info');
// State for dropdown menu
let showDropdown = $state(false);
// State for left column metadata
let showLeftMetadata = $state(false);
// State for image upload modal
let showUploadModal = $state(false);
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
showDropdown = false;
}
}
$effect(() => {
if (showDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
function parseReferences(text: string | undefined): string {
if (!text) return '';
// Use the new markdown-aware parser
return parseRefs(text);
}
function renderContent(text: string | undefined, isStoryLore: boolean = false): string {
if (!text) return '';
// For story lore, always use full markdown rendering
if (isStoryLore) {
return renderMarkdown(text);
}
// For other content, use reference parser (which auto-detects markdown)
return parseRefs(text);
}
// Load objects that are in this character's inventory
async function loadLinkedObjects() {
if (node.kind !== 'character' || !node.content.inventory_text) return;
loadingObjects = true;
try {
const mentions = extractMentions(node.content.inventory_text);
if (mentions.length === 0) return;
// Load all mentioned objects
const objects = await Promise.all(
mentions.map(async (slug) => {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
const obj = await response.json();
if (obj.kind === 'object') return obj;
}
return null;
})
);
linkedObjects = objects.filter((obj) => obj !== null) as ContentNode[];
} catch (err) {
console.error('Failed to load linked objects:', err);
} finally {
loadingObjects = false;
}
}
// Load images for the gallery
async function loadImages() {
console.log('📸 NodeDetail: Loading images for node:', node.slug);
loadingImages = true;
try {
// Use the proper attachments-based endpoint
const response = await fetch(`/api/nodes/${node.slug}/images`);
console.log('📸 NodeDetail: API response status:', response.status);
if (response.ok) {
images = await response.json();
console.log('📸 NodeDetail: Loaded images:', images);
console.log('📸 NodeDetail: images.length:', images.length);
} else {
console.error('📸 NodeDetail: API error:', response.status, response.statusText);
}
} catch (err) {
console.error('📸 NodeDetail: Failed to load images:', err);
} finally {
loadingImages = false;
console.log('📸 NodeDetail: loadingImages set to false');
}
}
$effect(() => {
loadLinkedObjects();
loadImages();
});
function formatFieldName(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/text$/, '')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
// Get the appropriate content fields based on node kind
function getContentFields(): Array<{ key: string; label: string }> {
const commonFields = [
{ key: 'appearance', label: 'Aussehen' },
{ key: 'lore', label: 'Geschichte' },
];
switch (node.kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten' },
{ key: 'glossary_text', label: 'Glossar' },
{ key: 'constraints', label: 'Einschränkungen' },
{ key: 'timeline_text', label: 'Zeitleiste' },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
];
case 'character':
return [
{ key: 'state_text', label: 'Aktuelle Situation' },
{ key: 'motivations', label: 'Motivationen' },
...commonFields,
{ key: 'voice_style', label: 'Sprechstil' },
{ key: 'capabilities', label: 'Fähigkeiten' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'relationships_text', label: 'Beziehungen' },
{ key: 'inventory_text', label: 'Inventar' },
{ key: 'timeline_text', label: 'Zeitleiste' },
{ key: 'constraints', label: 'Einschränkungen' },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Besonderheiten' },
{ key: 'constraints', label: 'Gefahren' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'state_text', label: 'Aktueller Zustand' },
{ key: 'timeline_text', label: 'Wichtige Ereignisse' },
];
case 'object':
return [
...commonFields,
{ key: 'capabilities', label: 'Eigenschaften' },
{ key: 'constraints', label: 'Einschränkungen' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'state_text', label: 'Zustand / Aufbewahrungsort' },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf' },
{ key: 'references', label: 'Referenzen' },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
];
default:
return commonFields;
}
}
const contentFields = getContentFields();
// Check if layout should be side-by-side
const isSideBySide = node.kind === 'character' || node.kind === 'object';
</script>
{#if !isSideBySide && (node.kind === 'world' || node.kind === 'place') && !loadingImages && (images.length > 0 || node.image_url)}
<!-- Fixed Full-Width Background Image for worlds and places -->
<div class="fixed inset-0 w-full h-full" style="z-index: -1;">
{#if images.length > 0 && images[0]?.image_url}
<!-- Use first image from gallery as background -->
<div class="relative w-full h-full">
<img
src={images[0].image_url}
alt={`Bild für ${node.title}`}
class="w-full h-full object-cover"
/>
</div>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<div class="relative w-full h-full">
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="w-full h-full object-cover"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
</div>
{/if}
</div>
{/if}
<div class="mx-auto max-w-6xl relative">
{#if isSideBySide}
<!-- Side-by-side layout for characters and objects -->
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Left column: Image, Title and metadata -->
<div class="flex-shrink-0 lg:w-1/3">
<div class="sticky top-8">
<!-- Image -->
{#if !loadingImages && (images.length > 0 || node.image_url)}
{#if images.length > 0}
<ImageGallery
{images}
nodeSlug={node.slug}
nodeKind={node.kind}
editable={isOwner}
onImageUpdate={loadImages}
/>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="{node.kind === 'character'
? 'aspect-[9/16] w-full'
: 'aspect-square w-full'} rounded-lg object-cover shadow-lg"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
{/if}
{/if}
<!-- Title and metadata -->
<div class="mt-6">
<div class="flex items-start justify-between gap-2">
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
<div class="flex gap-1 flex-shrink-0">
<!-- Collapsible metadata button -->
<button
onclick={() => (showLeftMetadata = !showLeftMetadata)}
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Metadaten anzeigen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if isOwner}
<!-- Upload button -->
<button
onclick={() => (showUploadModal = true)}
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Bilder hochladen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</button>
{/if}
</div>
</div>
{#if node.summary}
<p class="mt-2 text-base text-theme-text-secondary">{node.summary}</p>
{/if}
{#if showLeftMetadata}
<div class="mt-2 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
>
{node.visibility}
</span>
{#if node.world_slug}
<a
href="/worlds/{node.world_slug}"
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
>
🌍 {node.world_slug}
</a>
{/if}
{#if node.tags && node.tags.length > 0}
{#each node.tags as tag}
<span
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Right column: Content -->
<div class="flex-1">
<!-- Tab Navigation for all node types except stories -->
{#if node.kind !== 'story'}
<div
class="sticky top-0 z-10 mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1"
>
<div class="flex space-x-1">
<button
onclick={() => (activeTab = 'info')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Informationen
</button>
<button
onclick={() => (activeTab = 'memory')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'memory'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{node.kind === 'world'
? 'Historie'
: node.kind === 'place'
? 'Ereignisse'
: node.kind === 'object'
? 'Geschichte'
: 'Erinnerungen'}
</button>
{#if node.generation_prompt}
<button
onclick={() => (activeTab = 'prompt')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'prompt'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
KI-Generierung
</button>
{/if}
{#if node.custom_schema && node.custom_schema.fields.length > 0}
<button
onclick={() => (activeTab = 'custom')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'custom'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Zusatzfelder
</button>
{/if}
</div>
{#if isOwner}
<div class="relative dropdown-container mr-1">
<button
onclick={() => (showDropdown = !showDropdown)}
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Mehr Optionen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
>
<div class="py-1">
{#if editPath}
<a
href={editPath}
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Bearbeiten
</a>
{/if}
<button
onclick={onDelete}
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Content fields or Memory Tab or Prompt Tab -->
<div class="rounded-lg bg-theme-surface p-6 shadow">
{#if node.kind !== 'story' && activeTab === 'memory'}
<!-- Memory Tab Content -->
<NodeMemory
nodeSlug={node.slug}
nodeKind={node.kind}
memory={node.memory || null}
editable={isOwner}
onMemoryUpdate={(updatedMemory) => {
node.memory = updatedMemory;
}}
/>
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
<!-- Prompt Tab Content -->
<PromptInfo {node} />
{:else}
<!-- Regular Content Fields -->
<div class="space-y-6">
{#each contentFields as field}
{#if node.content?.[field.key]}
<div>
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
{field.label}
</h3>
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
{#if field.key === 'lore' && node.kind === 'story'}
<SmartMarkdown
text={node.content[field.key] || ''}
references={node.content.references}
/>
{:else if field.key.includes('text') || field.key === 'references'}
{@html parseReferences(node.content[field.key])}
{:else}
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
{/if}
</div>
</div>
{/if}
{/each}
</div>
<!-- Show linked objects for characters -->
{#if node.kind === 'character' && linkedObjects.length > 0}
<div class="border-t border-theme-border-subtle pt-6">
<h3 class="mb-4 text-lg font-medium text-theme-text-primary">
📒 Inventar-Objekte
</h3>
<div class="grid grid-cols-1 gap-4">
{#each linkedObjects as obj}
<a
href="/worlds/{node.world_slug}/objects/{obj.slug}"
class="block rounded-lg bg-theme-elevated p-4 transition-colors hover:bg-theme-interactive-hover"
>
<div class="flex items-start space-x-3">
{#if obj.image_url}
<img
src={obj.image_url}
alt={obj.title}
class="h-12 w-12 rounded object-cover"
/>
{/if}
<div class="flex-1">
<h4 class="font-medium text-theme-text-primary">{obj.title}</h4>
{#if obj.summary}
<p class="mt-1 text-sm text-theme-text-secondary">{obj.summary}</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
{/if}
</div>
</div>
</div>
{:else}
<!-- Traditional top-down layout for stories and worlds/places -->
<div
class="mx-auto max-w-4xl {node.kind === 'world' || node.kind === 'place'
? 'relative z-20'
: ''}"
style={node.kind === 'world' || node.kind === 'place'
? 'padding-top: 100vh; margin-top: -25vh;'
: ''}
>
<!-- Regular Image for stories and other content without sticky -->
{#if node.kind === 'story' && !loadingImages && (images.length > 0 || node.image_url)}
<div class="mb-6">
{#if images.length > 0}
<ImageGallery
{images}
nodeSlug={node.slug}
nodeKind={node.kind}
editable={isOwner}
onImageUpdate={loadImages}
/>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<div class="mb-6">
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="aspect-square w-full rounded-lg object-cover shadow-lg"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
</div>
{/if}
</div>
{/if}
<!-- Title and metadata -->
<div
class="mb-6 {node.kind === 'world' || node.kind === 'place'
? 'bg-theme-base/90 backdrop-blur-md rounded-lg p-6 shadow-lg'
: ''}"
>
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
{#if node.summary}
<p class="mt-2 text-lg text-theme-text-secondary">{node.summary}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
>
{node.visibility}
</span>
{#if node.world_slug}
<a
href="/worlds/{node.world_slug}"
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
>
🌍 {node.world_slug}
</a>
{/if}
{#if node.tags && node.tags.length > 0}
{#each node.tags as tag}
<span
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
{/if}
</div>
</div>
<!-- Tab Navigation for all node types except stories -->
{#if node.kind !== 'story'}
<div class="mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1">
<div class="flex space-x-1">
<button
onclick={() => (activeTab = 'info')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Informationen
</button>
<button
onclick={() => (activeTab = 'memory')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'memory'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{node.kind === 'world'
? 'Historie'
: node.kind === 'place'
? 'Ereignisse'
: node.kind === 'object'
? 'Geschichte'
: 'Erinnerungen'}
</button>
{#if node.generation_prompt}
<button
onclick={() => (activeTab = 'prompt')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'prompt'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
KI-Generierung
</button>
{/if}
</div>
{#if isOwner}
<div class="relative dropdown-container mr-1">
<button
onclick={() => (showDropdown = !showDropdown)}
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Mehr Optionen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
>
<div class="py-1">
{#if editPath}
<a
href={editPath}
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Bearbeiten
</a>
{/if}
<button
onclick={onDelete}
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Content fields or Memory Tab or Prompt Tab -->
<div class="rounded-lg bg-theme-surface p-6 shadow">
{#if node.kind !== 'story' && activeTab === 'memory'}
<!-- Memory Tab Content -->
<NodeMemory
nodeSlug={node.slug}
nodeKind={node.kind}
memory={node.memory || null}
editable={isOwner}
onMemoryUpdate={(updatedMemory) => {
node.memory = updatedMemory;
}}
/>
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
<!-- Prompt Tab Content -->
<PromptInfo {node} />
{:else if node.kind !== 'story' && activeTab === 'custom'}
<!-- Custom Fields Tab Content -->
<CustomFieldsDisplay schema={node.custom_schema} data={node.custom_data} />
{:else}
<!-- Regular Content Fields -->
<div class="space-y-6">
{#each contentFields as field}
{#if node.content?.[field.key]}
<div>
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
{field.label}
</h3>
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
{#if field.key === 'lore' && node.kind === 'story'}
<SmartMarkdown
text={node.content[field.key] || ''}
references={node.content.references}
/>
{:else if field.key.includes('text') || field.key === 'references'}
{@html parseReferences(node.content[field.key])}
{:else}
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Back link and bottom padding -->
{#if backPath}
<div
class="mt-6 {!isSideBySide && (node.kind === 'world' || node.kind === 'place')
? 'pb-[100vh]'
: 'pb-20'}"
>
<!-- Add massive bottom padding for world/place to show full background image -->
<a href={backPath} class="text-theme-primary-600 hover:text-theme-primary-500">
← Zurück zur Übersicht
</a>
</div>
{:else}
<!-- Add bottom padding even without back link -->
<div
class={!isSideBySide && (node.kind === 'world' || node.kind === 'place')
? 'pb-[100vh]'
: 'pb-20'}
></div>
{/if}
</div>
<!-- Image Upload Modal -->
{#if showUploadModal}
<ImageUploadModal
show={showUploadModal}
nodeSlug={node.slug}
onClose={() => (showUploadModal = false)}
onUploadComplete={loadImages}
/>
{/if}

View file

@ -0,0 +1,389 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import { goto } from '$app/navigation';
import AiImageGenerator from './AiImageGenerator.svelte';
import CollapsibleOptions from './CollapsibleOptions.svelte';
interface Props {
node: ContentNode;
onSave: (updatedNode: Partial<ContentNode>) => Promise<void>;
onCancel: () => void;
worldSlug?: string;
}
let { node, onSave, onCancel, worldSlug }: Props = $props();
// Basic fields
let title = $state(node.title);
let slug = $state(node.slug);
let summary = $state(node.summary || '');
let visibility = $state(node.visibility);
let tags = $state(node.tags.join(', '));
let imageUrl = $state(node.image_url);
// Content fields based on node type
let contentFields = $state<Record<string, any>>({});
let loading = $state(false);
let error = $state<string | null>(null);
// Initialize content fields based on node kind
$effect(() => {
const content = node.content || {};
switch (node.kind) {
case 'world':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
canon_facts_text: content.canon_facts_text || '',
glossary_text: content.glossary_text || '',
constraints: content.constraints || '',
timeline_text: content.timeline_text || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
case 'character':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
voice_style: content.voice_style || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
motivations: content.motivations || '',
secrets: content.secrets || '',
relationships_text: content.relationships_text || '',
inventory_text: content.inventory_text || '',
timeline_text: content.timeline_text || '',
state_text: content.state_text || '',
};
break;
case 'place':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
secrets: content.secrets || '',
};
break;
case 'object':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
};
break;
case 'story':
contentFields = {
lore: content.lore || '',
references: content.references || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
}
});
function generateSlug() {
if (title && slug === node.slug) {
slug = title
.toLowerCase()
.replace(/[äöü]/g, (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[char] || char)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
const updatedNode: Partial<ContentNode> = {
title,
slug,
summary,
visibility,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: contentFields,
image_url: imageUrl,
};
await onSave(updatedNode);
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
// Get field configuration based on node kind
function getFieldConfig() {
const kindNames = {
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
return {
title: kindNames[node.kind] || 'Node',
fields: getFieldsForKind(node.kind),
};
}
function getFieldsForKind(kind: NodeKind) {
const commonFields = [
{ key: 'appearance', label: 'Erscheinungsbild', rows: 3 },
{ key: 'lore', label: 'Geschichte & Bedeutung', rows: 4 },
];
switch (kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten', rows: 3 },
{ key: 'glossary_text', label: 'Glossar', rows: 3 },
{ key: 'constraints', label: 'Regeln & Einschränkungen', rows: 3 },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3 },
{ key: 'prompt_guidelines', label: 'KI-Richtlinien', rows: 3, optional: true },
];
case 'character':
return [
...commonFields,
{ key: 'voice_style', label: 'Stimme & Sprache', rows: 2 },
{ key: 'capabilities', label: 'Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen', rows: 3 },
{ key: 'motivations', label: 'Motivationen', rows: 3 },
{ key: 'relationships_text', label: 'Beziehungen', rows: 3, optional: true },
{ key: 'inventory_text', label: 'Inventar', rows: 3, optional: true },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3, optional: true },
{ key: 'secrets', label: 'Geheimnisse', rows: 2, optional: true },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Was ist hier möglich?', rows: 3 },
{ key: 'constraints', label: 'Gefahren & Einschränkungen', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
{ key: 'secrets', label: 'Verborgene Aspekte', rows: 2, optional: true },
];
case 'object':
return [
{ key: 'appearance', label: 'Aussehen & Material', rows: 3 },
{ key: 'lore', label: 'Herkunft & Geschichte', rows: 4 },
{ key: 'capabilities', label: 'Eigenschaften & Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen & Nachteile', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand & Besitzer', rows: 2, optional: true },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf / Plot', rows: 6 },
{ key: 'references', label: 'Referenzen', rows: 3, optional: true },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien', rows: 3, optional: true },
];
default:
return commonFields;
}
}
const config = getFieldConfig();
const fields = config.fields;
const optionalFields = fields.filter((f) => f.optional);
const requiredFields = fields.filter((f) => !f.optional);
let hasOptionalContent = $derived(
optionalFields.some((field) => contentFields[field.key]?.trim())
);
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-theme-text-primary">{config.title} bearbeiten</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Bearbeite die Details für "{node.title}"
</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
<!-- Basic Information -->
<div>
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Grundinformationen</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="title" class="block text-sm font-medium text-theme-text-primary">Name *</label
>
<input
type="text"
id="title"
bind:value={title}
onblur={generateSlug}
required
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-theme-text-primary">Slug *</label>
<input
type="text"
id="slug"
bind:value={slug}
required
pattern="[a-z0-9\-]+"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
</div>
<div class="mt-4">
<label for="summary" class="block text-sm font-medium text-theme-text-primary"
>Zusammenfassung</label
>
<textarea
id="summary"
bind:value={summary}
rows="2"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="visibility" class="block text-sm font-medium text-theme-text-primary"
>Sichtbarkeit</label
>
<select
id="visibility"
bind:value={visibility}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
>
<option value="private">Privat</option>
<option value="shared">Geteilt</option>
<option value="public">Öffentlich</option>
</select>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-theme-text-primary"
>Tags (kommagetrennt)</label
>
<input
type="text"
id="tags"
bind:value={tags}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Image Generation -->
{#if node.kind !== 'story'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Bild</h2>
<AiImageGenerator bind:imageUrl prompt={`${title}: ${contentFields.appearance}`} />
</div>
{/if}
<!-- Main Content Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Details</h2>
<div class="space-y-4">
{#each requiredFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
</div>
{/each}
</div>
</div>
<!-- Optional Fields -->
{#if optionalFields.length > 0}
<CollapsibleOptions title="Erweiterte Optionen" hasContent={hasOptionalContent}>
{#snippet children()}
{#each optionalFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
{#if field.key === 'inventory_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @objekt-slug um Objekte zu verlinken
</p>
{:else if field.key === 'state_text' && node.kind === 'object'}
<p class="mt-1 text-xs text-theme-text-secondary">
z.B. 'Im Besitz von @charakter-slug'
</p>
{/if}
</div>
{/each}
{/snippet}
</CollapsibleOptions>
{/if}
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button
type="button"
onclick={onCancel}
class="border-theme-border-default rounded-md border bg-white px-4 py-2 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-slate-50 dark:bg-slate-700 dark:hover:bg-slate-600"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Änderungen speichern'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,520 @@
<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>

View file

@ -0,0 +1,224 @@
<script lang="ts">
import type {
CustomFieldSchema,
CustomFieldData,
CustomFieldDefinition,
} from '$lib/types/customFields';
import { parseReferences } from '$lib/utils/markdown';
interface Props {
schema?: CustomFieldSchema;
data?: CustomFieldData;
}
let { schema, data = {} }: Props = $props();
// Group fields by category
let fieldsByCategory = $derived(() => {
if (!schema) return new Map();
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;
});
// Format value for display
function formatValue(field: CustomFieldDefinition, value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
switch (field.type) {
case 'boolean':
return value ? '✓ Ja' : '✗ Nein';
case 'number':
case 'range':
const formatted = typeof value === 'number' ? value.toString() : value;
return field.config.unit ? `${formatted} ${field.config.unit}` : formatted;
case 'date':
return new Date(value).toLocaleDateString('de-DE');
case 'select':
const choice = field.config.choices?.find((c) => c.value === value);
return choice?.label || value;
case 'multiselect':
if (Array.isArray(value)) {
const labels = value.map((v) => {
const choice = field.config.choices?.find((c) => c.value === v);
return choice?.label || v;
});
return labels.join(', ');
}
return value;
case 'list':
if (Array.isArray(value)) {
return value.join(', ');
}
return value;
case 'json':
return JSON.stringify(value, null, 2);
case 'formula':
// Formulas might return complex results
return typeof value === 'object' ? JSON.stringify(value) : value;
case 'reference':
if (Array.isArray(value)) {
return value.map((v) => `@${v}`).join(', ');
}
return value ? `@${value}` : '—';
case 'text':
default:
return value;
}
}
// Check if value is empty
function isEmpty(value: any): boolean {
return (
value === null ||
value === undefined ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && Object.keys(value).length === 0)
);
}
// Check if we have any non-empty values
let hasData = $derived(() => {
if (!schema || !data) return false;
return schema.fields.some((field) => !isEmpty(data[field.key]));
});
</script>
{#if schema && schema.fields.length > 0}
{#if !hasData}
<div class="text-center py-8 text-theme-text-secondary">
Keine benutzerdefinierten Daten vorhanden
</div>
{:else}
<div class="space-y-6">
{#each fieldsByCategory() as [category, fields]}
<div class="category-section">
{#if category !== '_uncategorized'}
<h3 class="text-lg font-medium mb-3 text-theme-text-primary border-b pb-2">
{category}
</h3>
{/if}
<div class="grid gap-4 md:grid-cols-2">
{#each fields as field}
{#if !isEmpty(data[field.key])}
<div class="field-display">
<dt class="text-sm font-medium text-theme-text-secondary mb-1">
{field.label}
</dt>
<dd class="text-theme-text-primary">
{#if field.type === 'range'}
<!-- Special display for range fields -->
<div class="flex items-center gap-2">
<div class="flex-1 bg-theme-elevated rounded-full h-2 relative">
<div
class="absolute top-0 left-0 h-full bg-theme-primary-600 rounded-full"
style="width: {((data[field.key] - (field.config.min ?? 0)) /
((field.config.max ?? 100) - (field.config.min ?? 0))) *
100}%"
></div>
</div>
<span class="text-sm font-medium">
{formatValue(field, data[field.key])}
</span>
</div>
{:else if field.type === 'text' && field.config.multiline}
<!-- Multiline text with markdown support -->
<div class="prose prose-sm max-w-none">
{@html parseReferences(data[field.key])}
</div>
{:else if field.type === 'json'}
<!-- JSON display -->
<pre class="text-xs bg-theme-elevated p-2 rounded overflow-x-auto">
<code>{formatValue(field, data[field.key])}</code>
</pre>
{:else if field.type === 'boolean'}
<!-- Boolean with icon -->
<span
class={data[field.key] ? 'text-theme-success' : 'text-theme-text-secondary'}
>
{formatValue(field, data[field.key])}
</span>
{:else if field.type === 'multiselect' || field.type === 'list'}
<!-- Tags display for arrays -->
<div class="flex flex-wrap gap-1">
{#each Array.isArray(data[field.key]) ? data[field.key] : [] as item}
<span class="inline-block px-2 py-0.5 bg-theme-elevated rounded text-sm">
{field.type === 'multiselect'
? field.config.choices?.find((c) => c.value === item)?.label || item
: item}
</span>
{/each}
</div>
{:else}
<!-- Default display -->
<span class="break-words">
{@html parseReferences(formatValue(field, data[field.key]))}
</span>
{/if}
</dd>
</div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
{:else}
<div class="text-center py-8 text-theme-text-secondary">
Keine benutzerdefinierten Felder definiert
</div>
{/if}
<style>
.field-display {
background-color: var(--theme-background-elevated);
border-radius: 0.5rem;
padding: 0.75rem;
}
.field-display dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.field-display dd {
margin-top: 0.25rem;
}
.category-section + .category-section {
padding-top: 1rem;
border-top: 1px solid var(--theme-border-default);
}
</style>

View file

@ -0,0 +1,833 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import type { CreateNodeRequest, UpdateNodeRequest } from '$lib/services/nodeService';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
import { NodeService } from '$lib/services/nodeService';
import AiPromptField from '$lib/components/AiPromptField.svelte';
import AiImageGenerator from '$lib/components/AiImageGenerator.svelte';
import CollapsibleOptions from '$lib/components/CollapsibleOptions.svelte';
import CharacterSelector from '$lib/components/CharacterSelector.svelte';
import PlaceSelector from '$lib/components/PlaceSelector.svelte';
import CustomFieldsManager from '$lib/components/customFields/CustomFieldsManager.svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
mode: 'create' | 'edit';
kind: NodeKind;
initialData?: Partial<ContentNode>;
worldSlug?: string;
worldTitle?: string;
onSubmit: (data: ContentNode) => Promise<void>;
onCancel: () => void;
}
let { mode, kind, initialData = {}, worldSlug, worldTitle, onSubmit, onCancel }: Props = $props();
// Basic fields
let title = $state(initialData.title || '');
let slug = $state(initialData.slug || '');
let summary = $state(initialData.summary || '');
let visibility = $state(initialData.visibility || 'private');
let tags = $state(initialData.tags?.join(', ') || '');
let imageUrl = $state(initialData.image_url || null);
let generationPrompt = $state<string | null>(null);
let generationContext = $state<any | null>(null);
// Content fields based on node type
let contentFields = $state<Record<string, any>>({});
// Custom fields
let customSchema = $state<CustomFieldSchema | undefined>(initialData.custom_schema);
let customData = $state<CustomFieldData>(initialData.custom_data || {});
// Story Builder fields (only for stories)
let selectedCharacters = $state<string[]>([]);
let selectedPlace = $state<string | null>(null);
let objectsInput = $state('');
let suggestions = $state<{ characters: string[]; places: string[]; objects: string[] }>({
characters: [],
places: [],
objects: [],
});
let loading = $state(false);
let error = $state<string | null>(null);
let showFormSections = $state(mode === 'edit');
let autoCreating = $state(false); // Neuer State für automatische Erstellung
// Initialize content fields based on node kind
$effect(() => {
const content = initialData.content || {};
switch (kind) {
case 'world':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
canon_facts_text: content.canon_facts_text || '',
glossary_text: content.glossary_text || '',
constraints: content.constraints || '',
timeline_text: content.timeline_text || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
case 'character':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
voice_style: content.voice_style || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
motivations: content.motivations || '',
secrets: content.secrets || '',
relationships_text: content.relationships_text || '',
inventory_text: content.inventory_text || '',
timeline_text: content.timeline_text || '',
state_text: content.state_text || '',
};
break;
case 'place':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
secrets: content.secrets || '',
};
break;
case 'object':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
};
break;
case 'story':
contentFields = {
lore: content.lore || '',
references: content.references || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
}
});
// Auto-generate slug when title changes
function generateSlug() {
if (title && (mode === 'create' || slug === initialData.slug)) {
slug = NodeService.generateSlug(title);
}
}
// Handle AI generation
async function handleAiGenerated(generated: any, prompt: string) {
title = generated.title;
summary = generated.summary;
tags = generated.tags.join(', ');
generationPrompt = prompt;
generationContext = generated.generationContext;
// Apply generated content
Object.keys(generated.content).forEach((key) => {
if (contentFields.hasOwnProperty(key)) {
contentFields[key] = generated.content[key];
}
});
generateSlug();
showFormSections = true;
// Automatisch erstellen nach AI-Generierung
if (mode === 'create') {
autoCreating = true;
// Kurze Verzögerung damit UI aktualisiert wird
await new Promise((resolve) => setTimeout(resolve, 100));
// Direkt submitten
await handleSubmitDirect();
autoCreating = false;
}
}
// Neue Funktion für direktes Submit ohne Event
async function handleSubmitDirect() {
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
// For stories, merge Story Builder references if no manual references provided
let finalContentFields = { ...contentFields };
if (kind === 'story' && !contentFields.references?.trim()) {
finalContentFields.references = buildReferences();
}
const createData: CreateNodeRequest = {
kind,
slug,
title,
summary,
visibility,
world_slug: worldSlug,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: finalContentFields,
generation_prompt: generationPrompt || undefined,
generation_model: generationPrompt ? 'gpt-5-mini' : undefined,
generation_date: generationPrompt ? new Date().toISOString() : undefined,
generation_context: generationContext || undefined,
};
const created = await NodeService.create(createData);
// Nächster Schritt: Bild generieren
loadingStore.nextStep('Node erfolgreich erstellt');
// Nach Erstellung: Bild automatisch generieren
if (created && (kind !== 'story' || contentFields.lore)) {
// Bild-Generierung im Hintergrund starten
await generateImageInBackground(created);
}
// Letzter Schritt: Fertigstellung
loadingStore.nextStep('Bild wird generiert');
loadingStore.complete('Erfolgreich erstellt!');
await onSubmit(created);
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
loading = false;
}
}
// Funktion für Hintergrund-Bildgenerierung
async function generateImageInBackground(node: ContentNode) {
try {
// Bestimme den richtigen Prompt basierend auf Node-Typ
let imagePrompt = '';
if (kind === 'story') {
imagePrompt = `${node.title}: ${contentFields.lore || ''}`;
} else {
imagePrompt = `${node.title}: ${contentFields.appearance || ''}`;
}
// Übersetze deutschen Text ins Englische
console.log(`Generiere Bild für ${kind} mit Aspect Ratio:`, getAspectRatio(kind));
const translateResponse = await fetch('/api/ai/translate-image-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
germanDescription: contentFields.appearance || contentFields.lore || '',
kind,
title: node.title,
style: 'fantasy',
}),
});
if (!translateResponse.ok) {
console.error('Übersetzung für Bild fehlgeschlagen');
return;
}
const translateData = await translateResponse.json();
const englishPrompt = translateData.englishPrompt;
// Generiere das Bild
const imageResponse = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
title: node.title,
description: englishPrompt,
style: 'fantasy',
aspectRatio: getAspectRatio(kind),
context: {
appearance: englishPrompt,
},
}),
});
if (!imageResponse.ok) {
console.error('Bildgenerierung fehlgeschlagen');
return;
}
const imageData = await imageResponse.json();
if (imageData.imageUrl) {
// Update die Node mit der Bild-URL
await NodeService.update(node.slug, {
image_url: imageData.imageUrl,
});
}
} catch (err) {
console.error('Fehler bei Hintergrund-Bildgenerierung:', err);
}
}
// Helper-Funktion für Aspect Ratio
function getAspectRatio(kind: NodeKind): string {
switch (kind) {
case 'world':
case 'place':
return '21:9';
case 'character':
return '9:16';
case 'object':
default:
return '1:1';
}
}
// Check if any content exists
let hasAnyContent = $derived(
title ||
summary ||
tags ||
Object.values(contentFields).some((value) => value?.trim()) ||
(kind === 'story' && (selectedCharacters.length > 0 || selectedPlace || objectsInput))
);
// Check optional fields for collapsible section
let hasOptionalContent = $derived(() => {
const optionalFields = getFieldsForKind(kind).filter((f) => f.optional);
return optionalFields.some((field) => contentFields[field.key]?.trim());
});
// Auto-show form when AI generates content
$effect(() => {
if (hasAnyContent && !showFormSections && mode === 'create') {
showFormSections = true;
}
});
// Story Builder functions
async function loadSuggestions() {
if (kind !== 'story' || !worldSlug) return;
try {
const [charactersRes, placesRes, objectsRes] = await Promise.all([
fetch(`/api/nodes?kind=character&world_slug=${worldSlug}`),
fetch(`/api/nodes?kind=place&world_slug=${worldSlug}`),
fetch(`/api/nodes?kind=object&world_slug=${worldSlug}`),
]);
if (charactersRes.ok) {
const chars = await charactersRes.json();
suggestions.characters = chars.map((c: any) => c.slug);
}
if (placesRes.ok) {
const places = await placesRes.json();
suggestions.places = places.map((p: any) => p.slug);
}
if (objectsRes.ok) {
const objs = await objectsRes.json();
suggestions.objects = objs.map((o: any) => o.slug);
}
} catch (err) {
console.error('Failed to load suggestions:', err);
}
}
function buildReferences(): string {
if (kind !== 'story') return '';
let refs = [];
if (selectedCharacters.length > 0) {
const cast = selectedCharacters.map((s) => `@${s}`).join(', ');
refs.push(`cast: ${cast}`);
}
if (selectedPlace) {
refs.push(`places: @${selectedPlace}`);
}
if (objectsInput.trim()) {
const objects = objectsInput
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => `@${s}`)
.join(', ');
refs.push(`objects: ${objects}`);
}
return refs.join('\n');
}
// Load suggestions for story builder
$effect(() => {
if (kind === 'story' && mode === 'create') {
loadSuggestions();
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
// For stories, merge Story Builder references if no manual references provided
let finalContentFields = { ...contentFields };
if (kind === 'story' && !contentFields.references?.trim()) {
finalContentFields.references = buildReferences();
}
if (mode === 'create') {
const createData: CreateNodeRequest = {
kind,
slug,
title,
summary,
visibility,
world_slug: worldSlug,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: finalContentFields,
custom_schema: customSchema,
custom_data: customData,
image_url: imageUrl || undefined,
generation_prompt: generationPrompt || undefined,
generation_model: generationPrompt ? 'gpt-5-mini' : undefined,
generation_date: generationPrompt ? new Date().toISOString() : undefined,
generation_context: generationContext || undefined,
};
const created = await NodeService.create(createData);
await onSubmit(created);
} else {
const updateData: UpdateNodeRequest = {
title,
slug,
summary,
visibility,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: contentFields,
custom_schema: customSchema,
custom_data: customData,
image_url: imageUrl || undefined,
};
const updated = await NodeService.update(initialData.slug!, updateData);
await onSubmit(updated);
}
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
// Get field configuration based on node kind
function getKindConfig() {
const kindNames = {
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
return {
title: kindNames[kind] || 'Node',
fields: getFieldsForKind(kind),
};
}
function getFieldsForKind(kind: NodeKind) {
const commonFields = [
{ key: 'appearance', label: 'Erscheinungsbild', rows: 3 },
{ key: 'lore', label: 'Geschichte & Bedeutung', rows: 4 },
];
switch (kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten', rows: 3 },
{ key: 'glossary_text', label: 'Glossar', rows: 3 },
{ key: 'constraints', label: 'Regeln & Einschränkungen', rows: 3 },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3 },
{ key: 'prompt_guidelines', label: 'KI-Richtlinien', rows: 3, optional: true },
];
case 'character':
return [
...commonFields,
{ key: 'voice_style', label: 'Stimme & Sprache', rows: 2 },
{ key: 'capabilities', label: 'Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen', rows: 3 },
{ key: 'motivations', label: 'Motivationen', rows: 3 },
{ key: 'relationships_text', label: 'Beziehungen', rows: 3, optional: true },
{ key: 'inventory_text', label: 'Inventar', rows: 3, optional: true },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3, optional: true },
{ key: 'secrets', label: 'Geheimnisse', rows: 2, optional: true },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Was ist hier möglich?', rows: 3 },
{ key: 'constraints', label: 'Gefahren & Einschränkungen', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
{ key: 'secrets', label: 'Verborgene Aspekte', rows: 2, optional: true },
];
case 'object':
return [
{ key: 'appearance', label: 'Aussehen & Material', rows: 3 },
{ key: 'lore', label: 'Herkunft & Geschichte', rows: 4 },
{ key: 'capabilities', label: 'Eigenschaften & Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen & Nachteile', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand & Besitzer', rows: 2, optional: true },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf / Plot', rows: 6 },
{ key: 'references', label: 'Referenzen', rows: 3, optional: true },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien', rows: 3, optional: true },
];
default:
return commonFields;
}
}
const config = getKindConfig();
const fields = config.fields;
const optionalFields = fields.filter((f) => f.optional);
const requiredFields = fields.filter((f) => !f.optional);
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-theme-text-primary">
{mode === 'create' ? `Neuer ${config.title}` : `${config.title} bearbeiten`}
</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
{#if mode === 'create'}
{#if worldTitle}
Erstelle einen neuen {config.title.toLowerCase()} in
<span class="font-semibold">{worldTitle}</span>
{:else}
Erstelle einen neuen {config.title.toLowerCase()}
{/if}
{:else}
Bearbeite die Details für "{initialData.title}"
{/if}
</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-theme-error/10 border border-theme-error/20 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
<!-- Story Elements Selection (only for stories) -->
{#if kind === 'story' && mode === 'create'}
<div class="space-y-4">
<CharacterSelector
worldSlug={worldSlug || ''}
{selectedCharacters}
onSelectionChange={(selected) => (selectedCharacters = selected)}
/>
<PlaceSelector
worldSlug={worldSlug || ''}
{selectedPlace}
onSelectionChange={(selected) => (selectedPlace = selected)}
/>
</div>
{/if}
<!-- AI Generation Field (only for create mode) -->
{#if mode === 'create'}
<div>
<AiPromptField
{kind}
context={{ world: worldTitle, worldData: $currentWorld }}
selectedCharacters={kind === 'story' ? selectedCharacters : undefined}
selectedPlace={kind === 'story' ? selectedPlace : undefined}
onGenerated={handleAiGenerated}
/>
</div>
{#if !showFormSections}
<div class="text-center">
<button
type="button"
onclick={() => (showFormSections = true)}
class="inline-flex items-center px-4 py-2 text-sm font-medium text-violet-600 hover:text-violet-500"
>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Mehr anzeigen
</button>
</div>
{/if}
{/if}
{#if showFormSections}
<!-- Basic Information -->
<div class={mode === 'create' ? 'border-t pt-6' : ''}>
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Grundinformationen</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="title" class="block text-sm font-medium text-theme-text-primary"
>Name *</label
>
<input
type="text"
id="title"
bind:value={title}
onblur={generateSlug}
required
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-theme-text-primary"
>Slug *</label
>
<input
type="text"
id="slug"
bind:value={slug}
required
pattern="[a-z0-9\-]+"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-4">
<label for="summary" class="block text-sm font-medium text-theme-text-primary"
>Zusammenfassung</label
>
<textarea
id="summary"
bind:value={summary}
rows="2"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="visibility" class="block text-sm font-medium text-theme-text-primary"
>Sichtbarkeit</label
>
<select
id="visibility"
bind:value={visibility}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
>
<option value="private">Privat</option>
<option value="shared">Geteilt</option>
<option value="public">Öffentlich</option>
</select>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-theme-text-primary"
>Tags (kommagetrennt)</label
>
<input
type="text"
id="tags"
bind:value={tags}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Story Builder - Additional Elements (only for stories) -->
{#if kind === 'story' && mode === 'create'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Weitere Story-Elemente</h2>
<p class="mb-4 text-sm text-theme-text-secondary">
Ergänze deine Story mit Objekten aus dieser Welt.
</p>
<div class="space-y-4">
<div>
<label for="objects" class="block text-sm font-medium text-theme-text-primary">
Objekte (kommagetrennt)
</label>
<input
type="text"
id="objects"
bind:value={objectsInput}
placeholder="magisches-amulett, altes-buch"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
{#if suggestions.objects.length > 0}
<p class="mt-1 text-xs text-theme-text-secondary">
Verfügbar: {suggestions.objects.slice(0, 5).join(', ')}{suggestions.objects
.length > 5
? '...'
: ''}
</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- Image Generation -->
{#if kind === 'story'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Story-Bild</h2>
<AiImageGenerator {kind} bind:imageUrl prompt={`${title}: ${contentFields.lore || ''}`} />
</div>
{:else}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Bild</h2>
<AiImageGenerator {kind} bind:imageUrl prompt={`${title}: ${contentFields.appearance}`} />
</div>
{/if}
<!-- Main Content Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">
{kind === 'story' ? 'Story-Inhalt' : 'Details'}
</h2>
<div class="space-y-4">
{#each requiredFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
{/each}
</div>
</div>
<!-- Optional Fields -->
{#if optionalFields.length > 0}
<CollapsibleOptions title="Erweiterte Optionen" hasContent={hasOptionalContent}>
{#snippet children()}
{#each optionalFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
{#if field.key === 'inventory_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @objekt-slug um Objekte zu verlinken
</p>
{:else if field.key === 'state_text' && kind === 'object'}
<p class="mt-1 text-xs text-theme-text-secondary">
z.B. 'Im Besitz von @charakter-slug'
</p>
{:else if field.key === 'relationships_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @slug für Referenzen zu anderen Charakteren
</p>
{:else if field.key === 'references' && kind === 'story'}
<p class="mt-1 text-xs text-theme-text-secondary">
Leer lassen, um die Story Builder Auswahl zu verwenden
</p>
{/if}
</div>
{/each}
{/snippet}
</CollapsibleOptions>
{/if}
<!-- Custom Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Benutzerdefinierte Felder</h2>
<CustomFieldsManager
node={initialData}
nodeSlug={initialData?.slug}
nodeKind={kind}
{worldSlug}
onSchemaChange={(schema) => (customSchema = schema)}
onDataChange={(data) => (customData = data)}
/>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button
type="button"
onclick={onCancel}
disabled={autoCreating}
class="border-theme-border-default rounded-md border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-theme-interactive-hover disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading || autoCreating}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{autoCreating
? 'Automatische Erstellung läuft...'
: loading
? mode === 'create'
? 'Wird erstellt...'
: 'Speichere...'
: mode === 'create'
? `${config.title} erstellen`
: 'Änderungen speichern'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,210 @@
export type NodeKind = 'world' | 'character' | 'object' | 'place' | 'story';
export type VisibilityLevel = 'private' | 'shared' | 'public';
export type StoryEntryType = 'narration' | 'dialog' | 'note';
export interface GenerationContext {
userPrompt: string;
systemPrompt: string;
worldContext?: string;
selectedCharacters?: Array<{
name: string;
slug: string;
summary?: string;
appearance?: string;
voice_style?: string;
motivations?: string;
capabilities?: string;
}>;
model: string;
timestamp: string;
}
export interface ContentNode {
id: string;
kind: NodeKind;
slug: string;
title: string;
summary?: string;
owner_id?: string;
visibility: VisibilityLevel;
tags: string[];
world_slug?: string;
content: ContentData;
memory?: CharacterMemory;
skills?: CharacterSkills;
custom_schema?: any; // Will be CustomFieldSchema from customFields.ts
custom_data?: Record<string, any>; // CustomFieldData
schema_version?: number;
generation_prompt?: string;
generation_model?: string;
generation_date?: string;
generation_context?: GenerationContext;
image_url?: string;
created_at: string;
updated_at: string;
}
export interface ContentData {
appearance?: string;
image_prompt?: string;
lore?: string;
voice_style?: string;
capabilities?: string;
constraints?: string;
motivations?: string;
secrets?: string;
relationships_text?: string;
inventory_text?: string;
timeline_text?: string;
glossary_text?: string;
canon_facts_text?: string;
state_text?: string;
prompt_guidelines?: string;
references?: string;
_links?: Record<string, string[]>;
_aliases?: string[];
_i18n?: Record<string, any>;
// Index signature für dynamische Content-Felder
[key: string]: string | Record<string, string[]> | string[] | Record<string, any> | undefined;
}
export interface StoryEntry {
id: string;
story_slug: string;
position: number;
type: StoryEntryType;
speaker_slug?: string;
body: string;
created_by?: string;
created_at: string;
}
export interface PromptTemplate {
id: string;
owner_id?: string;
world_slug?: string;
kind: NodeKind;
title: string;
prompt_template: string;
description?: string;
tags?: string[];
usage_count: number;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface PromptHistory {
id: string;
user_id: string;
node_id: string;
prompt: string;
response?: any;
model?: string;
created_at: string;
}
// Memory System Types
export interface ShortTermMemory {
id: string;
timestamp: string;
content: string;
location?: string;
involved?: string[];
tags?: string[];
importance: number;
decay_at: string;
}
export interface MediumTermMemory {
id: string;
timestamp: string;
content: string;
original_details?: string;
context?: string;
location?: string;
involved?: string[];
tags?: string[];
importance: number;
decay_at: string;
linked_memories?: string[];
}
export interface LongTermMemory {
id: string;
timestamp: string;
content: string;
emotional_weight: number;
category: 'trauma' | 'triumph' | 'relationship' | 'skill' | 'secret' | 'manual';
triggers?: string[];
effects?: string;
involved?: string[];
immutable: boolean;
}
export interface MemoryTraits {
memory_quality: 'excellent' | 'good' | 'average' | 'poor';
trauma_filter?: boolean;
selective_memory?: string[];
memory_conditions?: {
drunk?: 'partial_blackout' | 'full_blackout' | 'fuzzy';
stressed?: 'detail_loss' | 'time_gaps';
happy?: 'enhanced_positive' | 'forget_negative';
};
}
export interface CharacterMemory {
short_term_memory: ShortTermMemory[];
medium_term_memory: MediumTermMemory[];
long_term_memory: LongTermMemory[];
memory_traits: MemoryTraits;
last_processed?: string;
}
// Skills System Types
export interface Skill {
name: string;
level: number;
level_text?: string;
subskills?: Record<string, string>;
learned_from?: string;
learned_at?: string;
training_years?: number;
last_used?: string;
conditions?: Record<string, number>;
}
export interface LearningSkill {
name: string;
progress: number;
teacher?: string;
started: string;
blocked_by?: string;
next_milestone?: string;
}
export interface SkillCondition {
trigger: string;
effect: string;
}
export interface CharacterSkills {
primary: Skill[];
learning: LearningSkill[];
conditions: Record<string, SkillCondition>;
}
// Memory Event for story integration
export interface MemoryEvent {
id: string;
node_id: string;
story_id?: string;
event_timestamp: string;
event_type: 'observed' | 'experienced' | 'told' | 'dreamed' | 'remembered';
raw_event: string;
processed_memory?: any;
memory_tier?: 'short' | 'medium' | 'long';
importance?: number;
created_at: string;
updated_at?: string;
}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { currentWorld } from '$lib/stores/worldContext';
import type { ContentNode } from '$lib/types/content';
import NodeForm from '$lib/components/forms/NodeForm.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import { NodeService } from '$lib/services/nodeService';
let { data } = $props();
if (!data.user) {
goto('/auth/login');
}
if (!$currentWorld) {
goto('/');
}
const slug = $page.params.slug;
const worldSlug = $page.params.world;
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
async function loadCharacter() {
try {
node = await NodeService.get(slug);
// Ensure it's a character and belongs to this world
if (node && node.kind !== 'character') {
throw new Error('Dies ist kein Charakter');
}
if (node && node.world_slug !== worldSlug) {
throw new Error('Dieser Charakter gehört nicht zu dieser Welt');
}
isOwner = data.user?.id === node?.owner_id;
if (!isOwner) {
throw new Error('Du hast keine Berechtigung, diesen Charakter zu bearbeiten');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function handleSave(updatedNode: ContentNode) {
goto(`/worlds/${worldSlug}/characters/${updatedNode.slug}`);
}
function handleCancel() {
goto(`/worlds/${worldSlug}/characters/${node?.slug}`);
}
// Load character on mount
$effect(() => {
loadCharacter();
});
</script>
<svelte:head>
<title>{node ? `${node.title} bearbeiten - Worldream` : 'Charakter bearbeiten - Worldream'}</title
>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Charakter..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="rounded-md bg-red-50/50 p-4">
<h2 class="text-lg font-medium text-red-800">Fehler</h2>
<p class="text-sm text-red-600">{error}</p>
<div class="mt-4">
<a
href="/worlds/{worldSlug}/characters"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Zurück zu Charakteren
</a>
</div>
</div>
</div>
{:else if node}
<NodeForm
mode="edit"
kind="character"
initialData={node}
worldSlug={$currentWorld?.slug}
worldTitle={$currentWorld?.title}
onSubmit={handleSave}
onCancel={handleCancel}
/>
{/if}