mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
🔀 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:
commit
49a8c652da
475 changed files with 28008 additions and 22742 deletions
268
games/voxelava/apps/web/src/lib/components/auth/Register.svelte
Normal file
268
games/voxelava/apps/web/src/lib/components/auth/Register.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
83
games/voxelava/apps/web/src/lib/types/level.types.ts
Normal file
83
games/voxelava/apps/web/src/lib/types/level.types.ts
Normal 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;
|
||||
}
|
||||
200
games/worldream/apps/web/src/lib/components/AiGenerator.svelte
Normal file
200
games/worldream/apps/web/src/lib/components/AiGenerator.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
222
games/worldream/apps/web/src/lib/components/ImageGallery.svelte
Normal file
222
games/worldream/apps/web/src/lib/components/ImageGallery.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
141
games/worldream/apps/web/src/lib/components/ImageUploader.svelte
Normal file
141
games/worldream/apps/web/src/lib/components/ImageUploader.svelte
Normal 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>
|
||||
852
games/worldream/apps/web/src/lib/components/NodeDetail.svelte
Normal file
852
games/worldream/apps/web/src/lib/components/NodeDetail.svelte
Normal 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}
|
||||
389
games/worldream/apps/web/src/lib/components/NodeEditForm.svelte
Normal file
389
games/worldream/apps/web/src/lib/components/NodeEditForm.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
210
games/worldream/apps/web/src/lib/types/content.ts
Normal file
210
games/worldream/apps/web/src/lib/types/content.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue