Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,36 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@config '../tailwind.config.js';
/* Import theme CSS variables */
@import '$lib/themes/themes.css';
/* Define custom utilities for theme colors */
@layer utilities {
.bg-theme-base {
background-color: var(--theme-background-base);
}
.bg-theme-surface {
background-color: var(--theme-background-surface);
}
.bg-theme-elevated {
background-color: var(--theme-background-elevated);
}
.bg-theme-overlay {
background-color: var(--theme-background-overlay);
}
.bg-theme-subtle {
background-color: var(--theme-background-subtle, var(--theme-background-elevated));
}
}
/* Apply theme background colors using CSS variables */
html,
body {
background-color: var(--theme-background-base);
color: var(--theme-text-primary);
transition:
background-color 0.3s ease,
color 0.3s ease;
}

20
games/worldream/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient;
// Session helpers - returns mock session while transitioning to Mana Core Auth
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
getSession: () => Promise<Session | null>;
}
// interface PageData {}
// interface Error {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-50 dark:bg-gray-900">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,41 @@
/**
* Server Hooks for SvelteKit
* Auth is handled client-side via Mana Core Auth
* Supabase is still used for database operations
*
* TODO: Migrate API routes to use Mana Core Auth headers instead of session-based auth
*/
import { createClient } from '$lib/supabase/server';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Create Supabase client for database operations
event.locals.supabase = createClient(event);
// Provide session helpers for backwards compatibility
// These are stubs while transitioning to Mana Core Auth
event.locals.safeGetSession = async () => {
// In the future, this should validate the Mana Core Auth token
// For now, return a mock session for development
const {
data: { session },
error,
} = await event.locals.supabase.auth.getSession();
if (error || !session) {
return { session: null, user: null };
}
return { session, user: session.user };
};
event.locals.getSession = async () => {
const { session } = await event.locals.safeGetSession();
return session;
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
},
});
};

View file

@ -0,0 +1,266 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentNode, NodeKind } from '$lib/types/content';
import { aiLogger } from '$lib/utils/logger';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface EditContentOptions {
node: ContentNode;
command: string;
}
function getEditSystemPrompt(kind: NodeKind): string {
const basePrompt = `Du bist ein AI-Editor für Content Nodes in einem Worldbuilding-System.
Du erhältst die aktuellen Daten einer ${kind} Entity und einen Bearbeitungsbefehl.
DEINE AUFGABE:
- Interpretiere den Befehl und identifiziere welche Felder geändert werden sollen
- Gib NUR die geänderten Felder als JSON zurück
- Behalte den bestehenden Stil und Ton bei
- Bei slug-Änderungen: automatisch URL-safe formatieren (lowercase, hyphens)
- WICHTIG: Bei Umbenennungen durchsuche ALLE Felder nach dem alten Namen und aktualisiere sie
BEFEHLSTYPEN:
- "Benenne um zu X" title und slug ändern + ALLE anderen Felder nach altem Namen durchsuchen und ersetzen
- "Ändere [Feld] zu/auf X" spezifisches Feld updaten
- "Füge zu [Feld] hinzu: X" bestehenden Inhalt erweitern
- "Entferne aus [Feld]: X" spezifischen Inhalt löschen
- "Aktualisiere [Feld]: X" Feld komplett ersetzen
FELDER nach NodeKind:`;
const fieldMappings = {
character: `
- title: Name des Charakters
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Aussehen/Beschreibung
- content.lore: Hintergrundgeschichte
- content.voice_style: Sprechweise
- content.capabilities: Fähigkeiten
- content.constraints: Einschränkungen
- content.motivations: Ziele/Motivationen
- content.secrets: Geheimnisse
- content.relationships_text: Beziehungen
- content.inventory_text: Inventar/Besitz
- content.timeline_text: Wichtige Ereignisse
- content.state_text: Aktueller Zustand`,
place: `
- title: Name des Orts
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Erscheinungsbild
- content.lore: Geschichte/Bedeutung
- content.capabilities: Was ist möglich
- content.constraints: Gefahren/Einschränkungen
- content.state_text: Aktueller Zustand
- content.secrets: Verborgene Aspekte`,
object: `
- title: Name des Objekts
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Aussehen/Material
- content.lore: Herkunft/Geschichte
- content.capabilities: Eigenschaften/Fähigkeiten
- content.constraints: Einschränkungen/Nachteile
- content.state_text: Zustand/Besitzer`,
world: `
- title: Name der Welt
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Beschreibung
- content.lore: Geschichte/Lore
- content.canon_facts_text: Kanon-Fakten
- content.glossary_text: Glossar
- content.constraints: Regeln/Einschränkungen
- content.timeline_text: Zeitlinie
- content.prompt_guidelines: KI-Richtlinien`,
story: `
- title: Titel der Geschichte
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.lore: Story-Verlauf/Plot
- content.references: Referenzen/Verweise
- content.prompt_guidelines: LLM-Richtlinien`,
};
return (
basePrompt +
fieldMappings[kind] +
`
BEISPIELE:
User: "Benenne um zu Gandalf der Graue"
{"title": "Gandalf der Graue", "slug": "gandalf-der-graue", "content": {"appearance": "Gandalf der Graue trägt...", "lore": "Gandalf der Graue wurde..."}}
(Alle Felder durchsuchen wo "Gandalf" erwähnt wird und zu "Gandalf der Graue" ändern)
User: "Füge zur Erscheinung hinzu: trägt einen blauen Mantel"
{"content": {"appearance": "[BESTEHENDER TEXT] trägt einen blauen Mantel"}}
User: "Ändere die Fähigkeiten zu: Meister der Feuermagie"
{"content": {"capabilities": "Meister der Feuermagie"}}
WICHTIG:
- Gib NUR ein gültiges JSON-Objekt zurück
- Keine Erklärungen oder zusätzlicher Text
- Bei content-Feldern: Nur die geänderten Unterfelder einschließen
- Bestehende @mentions und Formatierung beibehalten`
);
}
export async function editContentWithAI(
options: EditContentOptions
): Promise<Partial<ContentNode>> {
const { node, command } = options;
aiLogger.info(`Starting AI content editing for ${node.kind}`, {
nodeId: node.id,
nodeSlug: node.slug,
commandLength: command.length,
});
const systemPrompt = getEditSystemPrompt(node.kind);
const endTimer = aiLogger.startTimer(`editContent-${node.kind}`);
try {
const userPrompt = `AKTUELLE DATEN:
${JSON.stringify(
{
title: node.title,
slug: node.slug,
summary: node.summary,
tags: node.tags,
content: node.content,
},
null,
2
)}
BEFEHL: ${command}`;
const requestParams = {
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
max_completion_tokens: 5000,
// Keine temperature - GPT-4o-mini unterstützt nur default (1.0)
};
aiLogger.apiRequest('OpenAI', 'chat.completions.create', requestParams);
const completion = await openai.chat.completions.create(requestParams as any);
const duration = endTimer();
if (!completion.choices[0]?.message?.content) {
throw new Error('No content received from AI');
}
const rawResponse = completion.choices[0].message.content;
aiLogger.debug('Raw AI editing response', {
contentLength: rawResponse.length,
first500Chars: rawResponse.substring(0, 500),
tokensUsed: completion.usage?.completion_tokens || 0,
finishReason: completion.choices[0].finish_reason,
});
// Parse AI response
let updates: Partial<ContentNode>;
try {
updates = JSON.parse(rawResponse);
} catch (parseError) {
aiLogger.error('Failed to parse AI response as JSON', { rawResponse, parseError });
throw new Error('AI returned invalid JSON format');
}
// Validate and clean updates
const cleanedUpdates = validateAndCleanUpdates(updates, node);
aiLogger.apiResponse('OpenAI', 'chat.completions.create', completion, duration);
aiLogger.info('Content edited successfully', {
nodeSlug: node.slug,
fieldsChanged: Object.keys(cleanedUpdates),
duration,
});
return cleanedUpdates;
} catch (error) {
const duration = endTimer();
aiLogger.error('AI content editing failed', {
nodeSlug: node.slug,
command,
duration,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
function validateAndCleanUpdates(updates: any, originalNode: ContentNode): Partial<ContentNode> {
const cleaned: Partial<ContentNode> = {};
// Validate basic fields
if (updates.title && typeof updates.title === 'string') {
cleaned.title = updates.title.trim();
}
if (updates.slug && typeof updates.slug === 'string') {
// Ensure slug is URL-safe
cleaned.slug = updates.slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (updates.summary && typeof updates.summary === 'string') {
cleaned.summary = updates.summary.trim();
}
if (updates.tags && Array.isArray(updates.tags)) {
cleaned.tags = updates.tags
.filter((tag: unknown): tag is string => typeof tag === 'string')
.map((tag: string) => tag.trim());
}
// Validate content updates
if (updates.content && typeof updates.content === 'object') {
// WICHTIG: Starte mit dem originalen Content, nicht mit einem leeren Objekt!
// So bleiben alle nicht-geänderten Felder erhalten
cleaned.content = { ...(originalNode.content || {}) };
// Merge content fields, handling append operations
for (const [key, value] of Object.entries(updates.content)) {
if (typeof value === 'string') {
const trimmedValue = value.trim();
// Update or add the field
cleaned.content[key] = trimmedValue;
} else if (value === null || value === undefined) {
// Allow deletion of fields if explicitly set to null
delete cleaned.content[key];
}
}
}
// Always update timestamp when making changes
if (Object.keys(cleaned).length > 0) {
cleaned.updated_at = new Date().toISOString();
}
return cleaned;
}

View file

@ -0,0 +1,162 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
import { GEMINI_API_KEY } from '$env/static/private';
import type { NodeKind } from '$lib/types/content';
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
interface ImageGenerationOptions {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: {
world?: string;
appearance?: string;
atmosphere?: string;
};
}
export async function generateImage(options: ImageGenerationOptions): Promise<{
imageUrl: string;
prompt: string;
}> {
const { kind, title, description, style = 'fantasy', context } = options;
const prompt = buildImagePrompt(kind, title, description, style, context);
// WICHTIG: Gemini API unterstützt derzeit keine direkte Bildgenerierung
// Die "Nano Banana" Bildgenerierung ist nur über die Gemini Web-App verfügbar
// Wir generieren stattdessen einen optimierten Prompt für externe Dienste
const model = genAI.getGenerativeModel({
model: 'gemini-1.5-flash', // Verwende das Standard-Modell für Prompt-Optimierung
});
try {
// Generiere einen optimierten Bildprompt mit Gemini
const result = await model.generateContent({
contents: [
{
role: 'user',
parts: [
{
text: `Create an optimized image generation prompt for: ${prompt}.
Make it detailed, descriptive, and suitable for image generation AI.
Keep it under 500 characters. Return only the prompt, no explanation.`,
},
],
},
],
generationConfig: {
temperature: 0.8,
maxOutputTokens: 200,
},
});
const response = await result.response;
const optimizedPrompt = response.text() || prompt;
// Für Demo-Zwecke: Generiere eine Placeholder-URL mit dem Prompt
// In Produktion: Hier würde man einen echten Bildgenerierungsdienst aufrufen
const placeholderUrl = `https://via.placeholder.com/1024x1024/4F46E5/ffffff?text=${encodeURIComponent(title.substring(0, 20))}`;
console.log('Optimized prompt for external image generation:', optimizedPrompt);
return {
imageUrl: placeholderUrl, // Placeholder - ersetze mit echtem Bildgenerierungsdienst
prompt: optimizedPrompt,
};
} catch (error) {
console.error('Fehler bei Prompt-Generierung:', error);
throw new Error(
`Prompt-Generierung fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`
);
}
}
function buildImagePrompt(
kind: NodeKind,
title: string,
description?: string,
style: string = 'fantasy',
context?: any
): string {
const styleDescriptions = {
realistic: 'photorealistic, highly detailed, professional photography',
fantasy: 'fantasy art style, magical atmosphere, detailed illustration',
anime: 'anime art style, vibrant colors, expressive',
'concept-art': 'concept art, professional digital painting, atmospheric',
illustration: 'detailed illustration, artistic, hand-drawn quality',
};
const kindPrompts: Record<NodeKind, string> = {
character: `Character portrait of ${title}. ${description || ''} ${context?.appearance || ''}. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
place: `Environment concept art of ${title}. ${description || ''} ${context?.atmosphere || ''}. Wide shot, establishing view. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
object: `Item design of ${title}. ${description || ''} Centered composition, clear details. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
world: `World map or panoramic view of ${title}. ${description || ''} Epic scale, diverse landscapes. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
story: `Key scene illustration from "${title}". ${description || ''} Dramatic composition, narrative moment. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
};
let fullPrompt = kindPrompts[kind];
if (context?.world) {
fullPrompt += ` Set in the world of ${context.world}.`;
}
// Zusätzliche Qualitätshinweise
fullPrompt += ' High quality, detailed, professional artwork. No text, no watermarks.';
return fullPrompt;
}
export async function analyzeImage(imageUrl: string): Promise<{
description: string;
tags: string[];
colors: string[];
}> {
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
});
try {
const result = await model.generateContent({
contents: [
{
role: 'user',
parts: [
{
text: 'Analyze this image and provide a description, relevant tags, and dominant colors in JSON format.',
},
{
inlineData: {
mimeType: 'image/jpeg',
data: imageUrl, // Base64 oder URL
},
},
],
},
],
generationConfig: {
temperature: 0.3,
maxOutputTokens: 1024,
responseMimeType: 'application/json',
},
});
const response = await result.response;
const analysis = JSON.parse(response.text());
return {
description: analysis.description || '',
tags: analysis.tags || [],
colors: analysis.colors || [],
};
} catch (error) {
console.error('Fehler bei Bildanalyse:', error);
throw new Error('Bildanalyse fehlgeschlagen');
}
}

View file

@ -0,0 +1,441 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentData, NodeKind } from '$lib/types/content';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface StreamOptions {
kind: NodeKind;
prompt: string;
context?: any;
onChunk?: (chunk: string) => void;
onComplete?: (result: any) => void;
}
// Streaming-Version für bessere UX (zeigt Fortschritt)
export async function generateContentStream(options: StreamOptions): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}> {
const { kind, prompt, context, onChunk, onComplete } = options;
if (kind === 'world') {
return generateWorldContentStream(prompt, context, onChunk, onComplete);
}
// Für andere Content-Typen
const systemPrompt = getStreamingPrompt(kind, context);
const stream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 2000,
});
let fullContent = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
fullContent += content;
onChunk?.(content);
}
// Parse das Ergebnis
try {
const result = parseGeneratedContent(fullContent, kind);
onComplete?.(result);
return result;
} catch (error) {
console.error('Failed to parse generated content:', error);
// Fallback: Versuche trotzdem etwas zu extrahieren
return extractFallbackContent(fullContent, kind);
}
}
// Optimierte zweistufige Welt-Generierung mit Streaming
async function generateWorldContentStream(
prompt: string,
context: any,
onChunk?: (chunk: string) => void,
onComplete?: (result: any) => void
): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}> {
// Stufe 1: Basis-Info mit strukturiertem Output
onChunk?.('🌍 Erstelle Grundlagen der Welt...\n\n');
const basePrompt = `Erstelle eine neue Welt. Antworte in folgendem Format:
TITEL: [Name der Welt]
ZUSAMMENFASSUNG: [1-2 Sätze Beschreibung]
TAGS: [tag1, tag2, tag3]
ERSCHEINUNG:
[2-3 Absätze über Landschaften und Atmosphäre]
GESCHICHTE:
[2-3 Absätze über Entstehung und Historie]
REGELN:
[Wichtigste Naturgesetze und Einschränkungen als Stichpunkte]`;
const baseStream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: 'Du bist ein kreativer Weltenbauer.' },
{ role: 'user', content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 1000,
});
let baseContent = '';
for await (const chunk of baseStream) {
const content = chunk.choices[0]?.delta?.content || '';
baseContent += content;
onChunk?.(content);
}
// Parse Basis-Ergebnis
const baseResult = parseWorldBase(baseContent);
// Stufe 2: Details
onChunk?.('\n\n📚 Erweitere Details...\n\n');
const detailPrompt = `Für die Welt "${baseResult.title}", erstelle:
CANON-FAKTEN:
[3-5 unveränderliche Wahrheiten]
GLOSSAR:
[5-7 wichtige Begriffe mit Erklärungen]
TIMELINE:
[3-5 historische Ereignisse]
RICHTLINIEN:
[Stil-Richtlinien für weitere Inhalte]`;
const detailStream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: 'Erweitere die Welt-Details.' },
{ role: 'user', content: detailPrompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 800,
});
let detailContent = '';
for await (const chunk of detailStream) {
const content = chunk.choices[0]?.delta?.content || '';
detailContent += content;
onChunk?.(content);
}
const detailResult = parseWorldDetails(detailContent);
const finalResult = {
title: baseResult.title,
summary: baseResult.summary,
tags: baseResult.tags,
content: {
appearance: baseResult.appearance,
lore: baseResult.lore,
constraints: baseResult.constraints,
canon_facts_text: detailResult.canon_facts_text,
glossary_text: detailResult.glossary_text,
timeline_text: detailResult.timeline_text,
prompt_guidelines: detailResult.prompt_guidelines,
},
};
onComplete?.(finalResult);
return finalResult;
}
// Helper: Parse strukturierten Text für Welt-Basis
function parseWorldBase(text: string): any {
const lines = text.split('\n');
const result: any = {
title: '',
summary: '',
tags: [],
appearance: '',
lore: '',
constraints: '',
};
let currentSection = '';
let sectionContent: string[] = [];
for (const line of lines) {
if (line.startsWith('TITEL:')) {
result.title = line.replace('TITEL:', '').trim();
} else if (line.startsWith('ZUSAMMENFASSUNG:')) {
result.summary = line.replace('ZUSAMMENFASSUNG:', '').trim();
} else if (line.startsWith('TAGS:')) {
result.tags = line
.replace('TAGS:', '')
.trim()
.split(',')
.map((t) => t.trim());
} else if (line.startsWith('ERSCHEINUNG:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'appearance';
sectionContent = [];
} else if (line.startsWith('GESCHICHTE:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'lore';
sectionContent = [];
} else if (line.startsWith('REGELN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'constraints';
sectionContent = [];
} else if (currentSection) {
sectionContent.push(line);
}
}
// Letzten Abschnitt speichern
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
return result;
}
// Helper: Parse Details
function parseWorldDetails(text: string): any {
const lines = text.split('\n');
const result: any = {};
let currentSection = '';
let sectionContent: string[] = [];
for (const line of lines) {
if (line.startsWith('CANON-FAKTEN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'canon_facts_text';
sectionContent = [];
} else if (line.startsWith('GLOSSAR:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'glossary_text';
sectionContent = [];
} else if (line.startsWith('TIMELINE:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'timeline_text';
sectionContent = [];
} else if (line.startsWith('RICHTLINIEN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'prompt_guidelines';
sectionContent = [];
} else if (currentSection) {
sectionContent.push(line);
}
}
// Letzten Abschnitt speichern
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
return result;
}
// Helper für andere Content-Typen
function getStreamingPrompt(kind: NodeKind, context?: any): string {
const prompts: Record<NodeKind, string> = {
character: `Erstelle einen Charakter. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
GESCHICHTE:
[Hintergrund]
FÄHIGKEITEN:
[Liste]
MOTIVATION:
[Ziele und Antriebe]`,
place: `Erstelle einen Ort. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
GESCHICHTE:
[Hintergrund]
BESONDERHEITEN:
[Was macht diesen Ort einzigartig]`,
object: `Erstelle ein Objekt. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
FUNKTION:
[Zweck und Fähigkeiten]
GESCHICHTE:
[Herkunft]`,
story: `Erstelle eine Story. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Plot-Zusammenfassung]
TAGS: [genre1, genre2]
HANDLUNG:
[Story-Verlauf]
CHARAKTERE:
[Wichtige Personen]
WENDEPUNKTE:
[Schlüsselmomente]`,
world: '', // Wird oben speziell behandelt
};
return prompts[kind] || prompts.character;
}
// Parse generierte Inhalte aus strukturiertem Text
function parseGeneratedContent(text: string, kind: NodeKind): any {
// Ähnlich wie parseWorldBase, aber für alle Content-Typen
const lines = text.split('\n');
const result: any = {
title: '',
summary: '',
tags: [],
content: {},
};
// Extrahiere Basis-Info
for (const line of lines) {
if (line.startsWith('TITEL:')) {
result.title = line.replace('TITEL:', '').trim();
} else if (line.startsWith('ZUSAMMENFASSUNG:')) {
result.summary = line.replace('ZUSAMMENFASSUNG:', '').trim();
} else if (line.startsWith('TAGS:')) {
result.tags = line
.replace('TAGS:', '')
.trim()
.split(',')
.map((t) => t.trim());
}
}
// Content-spezifische Felder
if (kind === 'character') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
result.content.capabilities = extractSection(text, 'FÄHIGKEITEN:');
result.content.motivations = extractSection(text, 'MOTIVATION:');
} else if (kind === 'place') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
result.content.capabilities = extractSection(text, 'BESONDERHEITEN:');
} else if (kind === 'object') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.capabilities = extractSection(text, 'FUNKTION:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
} else if (kind === 'story') {
result.content.lore = extractSection(text, 'HANDLUNG:');
result.content.references = extractSection(text, 'CHARAKTERE:');
result.content.timeline_text = extractSection(text, 'WENDEPUNKTE:');
}
return result;
}
// Helper: Extrahiere Sektion aus Text
function extractSection(text: string, marker: string): string {
const startIndex = text.indexOf(marker);
if (startIndex === -1) return '';
const nextMarkers = [
'TITEL:',
'ZUSAMMENFASSUNG:',
'TAGS:',
'AUSSEHEN:',
'GESCHICHTE:',
'FÄHIGKEITEN:',
'MOTIVATION:',
'BESONDERHEITEN:',
'FUNKTION:',
'HANDLUNG:',
'CHARAKTERE:',
'WENDEPUNKTE:',
'CANON-FAKTEN:',
'GLOSSAR:',
'TIMELINE:',
'RICHTLINIEN:',
'REGELN:',
];
let endIndex = text.length;
for (const nextMarker of nextMarkers) {
const idx = text.indexOf(nextMarker, startIndex + marker.length);
if (idx > -1 && idx < endIndex) {
endIndex = idx;
}
}
return text.substring(startIndex + marker.length, endIndex).trim();
}
// Fallback wenn Parsing fehlschlägt
function extractFallbackContent(text: string, kind: NodeKind): any {
// Versuche zumindest Titel zu extrahieren
const titleMatch = text.match(/TITEL:\s*(.+)/i);
const summaryMatch = text.match(/ZUSAMMENFASSUNG:\s*(.+)/i);
return {
title: titleMatch?.[1] || 'Unbenannt',
summary: summaryMatch?.[1] || text.substring(0, 100),
tags: [],
content: {
lore: text, // Speichere alles als lore
},
};
}

View file

@ -0,0 +1,564 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentData, NodeKind } from '$lib/types/content';
import { aiLogger } from '$lib/utils/logger';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface GenerateContentOptions {
kind: NodeKind;
prompt: string;
context?: {
world?: string;
worldData?: any;
existingCharacters?: string[];
existingPlaces?: string[];
existingObjects?: string[];
selectedCharacters?: any[];
selectedPlace?: any;
};
}
export async function generateContent(options: GenerateContentOptions): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
generationContext: any;
}> {
const { kind, prompt, context } = options;
aiLogger.info(`Starting content generation for ${kind}`, {
kind,
promptLength: prompt.length,
hasContext: !!context,
});
const systemPrompt = getSystemPrompt(kind, context);
const timer = aiLogger.startTimer(`generateContent-${kind}`);
// Build complete generation context for storage
const generationContext = {
userPrompt: prompt,
systemPrompt: systemPrompt,
worldContext: context?.world,
worldDetails: context?.worldData
? {
title: context.worldData.title,
summary: context.worldData.summary,
appearance: context.worldData.content?.appearance,
}
: undefined,
selectedCharacters: context?.selectedCharacters || undefined,
selectedPlace: context?.selectedPlace || undefined,
model: 'gpt-5-mini',
timestamp: new Date().toISOString(),
};
try {
const requestParams = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{ role: 'user' as const, content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini (andere Werte nicht unterstützt)
response_format: { type: 'json_object' as const },
max_completion_tokens: 10000, // Einheitliches Token-Limit für alle
};
aiLogger.apiRequest('OpenAI', 'chat.completions.create', requestParams);
const completion = await openai.chat.completions.create(requestParams);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'chat.completions.create', completion, duration);
const rawContent = completion.choices[0].message.content || '{}';
// Enhanced logging for story generation debugging
if (kind === 'story') {
console.log('🎬 Story Generation Debug:', {
hasSelectedCharacters: !!context?.selectedCharacters?.length,
selectedCharacters: context?.selectedCharacters?.map((c: any) => ({
name: c.name,
slug: c.slug,
})),
rawResponsePreview: rawContent.substring(0, 1000),
});
// Log the actual parsed result
try {
const parsedForDebug = JSON.parse(rawContent);
if (parsedForDebug.content?.lore) {
console.log('📝 Generated story lore:', parsedForDebug.content.lore.substring(0, 500));
}
} catch (e) {
console.log('Could not parse for debug');
}
}
aiLogger.debug('Raw AI response', {
contentLength: rawContent.length,
first500Chars: rawContent.substring(0, 500),
tokensUsed: completion.usage?.completion_tokens,
finishReason: completion.choices[0].finish_reason,
});
let result: any;
// Check if response was cut off
if (completion.choices[0].finish_reason === 'length') {
aiLogger.warn('Response was truncated due to token limit', {
tokensUsed: completion.usage?.completion_tokens,
contentLength: rawContent.length,
});
}
try {
result = JSON.parse(rawContent);
} catch (parseError) {
aiLogger.error('Failed to parse AI response', {
error: parseError,
rawContent: rawContent.substring(0, 1000),
finishReason: completion.choices[0].finish_reason,
});
// If content is just "{}" and we hit token limit, throw error
if (rawContent.trim() === '{}' && completion.choices[0].finish_reason === 'length') {
throw new Error(
'AI-Generierung fehlgeschlagen: Token-Limit erreicht. Bitte versuchen Sie einen kürzeren Prompt.'
);
}
// Fallback: Try to extract JSON
const jsonMatch = rawContent.match(/{[\s\S]*}/);
if (jsonMatch) {
try {
result = JSON.parse(jsonMatch[0]);
} catch (e) {
result = {};
}
} else {
result = {};
}
}
// Post-process for story content: Replace REF_X with actual @slugs if AI generated them incorrectly
if (kind === 'story' && result.content?.lore) {
let processedLore = result.content.lore;
let replacementsMade = false;
// Check if there are REF_X placeholders
if (/REF_\d+/.test(processedLore)) {
console.warn('⚠️ Found REF_X placeholders in generated story, attempting to fix...');
// Build a mapping of all possible references
const refMapping: Record<number, string> = {};
let refIndex = 0;
// Add characters first
if (context?.selectedCharacters?.length) {
context.selectedCharacters.forEach((char: any) => {
refMapping[refIndex] = `@${char.slug}`;
console.log(`Mapping REF_${refIndex} → @${char.slug} (${char.name})`);
refIndex++;
});
}
// Add place if selected
if (context?.selectedPlace) {
refMapping[refIndex] = `@${context.selectedPlace.slug}`;
console.log(
`Mapping REF_${refIndex} → @${context.selectedPlace.slug} (${context.selectedPlace.name})`
);
refIndex++;
}
// Replace all REF_X with mapped values
for (const [index, replacement] of Object.entries(refMapping)) {
const refPattern = new RegExp(`REF_${index}(?!\\d)`, 'g');
const before = processedLore;
processedLore = processedLore.replace(refPattern, replacement);
if (before !== processedLore) {
replacementsMade = true;
console.log(`✅ Replaced REF_${index} with ${replacement}`);
}
}
// Check for any remaining REF_X patterns
const remainingRefs = processedLore.match(/REF_\d+/g);
if (remainingRefs) {
console.error('❌ Still found unmatched REF patterns:', remainingRefs);
console.log('Available mappings were:', refMapping);
}
if (replacementsMade) {
result.content.lore = processedLore;
console.log('✨ Fixed story content with proper @references');
}
}
}
aiLogger.info(`Content generated successfully for ${kind}`, {
title: result.title,
tagsCount: result.tags?.length || 0,
duration,
});
return {
title: result.title || 'Unbenannt',
summary: result.summary || '',
content: result.content || {},
tags: result.tags || [],
generationContext,
};
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'chat.completions.create', error, duration);
throw error;
}
}
function getSystemPrompt(kind: NodeKind, context?: any): string {
const basePrompt = `Du bist ein kreativer Weltenbauer und Geschichtenerzähler.
Erstelle detaillierte, konsistente und fesselnde Inhalte für eine text-first Worldbuilding-Plattform.
Antworte IMMER im JSON-Format.`;
const kindPrompts: Record<NodeKind, string> = {
character: `
${basePrompt}
WICHTIG: Antworte NUR mit validem JSON!
Erstelle einen Charakter:
{
"title": "Name",
"summary": "Beschreibung in 1-2 Sätzen",
"tags": ["tag1", "tag2"],
"content": {
"appearance": "Aussehen (50-100 Wörter)",
"lore": "Hintergrund (50-100 Wörter)",
"voice_style": "Sprechstil",
"capabilities": "Fähigkeiten (Stichpunkte)",
"constraints": "Schwächen (Stichpunkte)",
"motivations": "Ziele (Stichpunkte)",
"secrets": "1-2 Geheimnisse",
"relationships_text": "Beziehungen",
"inventory_text": "Wichtige Gegenstände",
"state_text": "Aktueller Status"
}
}`,
world: `
${basePrompt}
WICHTIG: Antworte NUR mit validem JSON ohne zusätzlichen Text!
Erstelle eine Welt mit folgender JSON-Struktur:
{
"title": "Name der Welt",
"summary": "Kurze Beschreibung der Welt in 1-2 Sätzen",
"tags": ["genre1", "genre2", "setting"],
"content": {
"appearance": "Beschreibung der Welt, Landschaften, Atmosphäre (100-200 Wörter)",
"lore": "Geschichte und Entstehung der Welt (100-200 Wörter)",
"canon_facts_text": "3-5 unveränderliche Wahrheiten als kurze Liste",
"glossary_text": "5-7 wichtige Begriffe mit kurzen Erklärungen",
"constraints": "Naturgesetze und Einschränkungen als Stichpunkte",
"timeline_text": "3-5 wichtige historische Ereignisse",
"prompt_guidelines": "Stil-Richtlinien für weitere Generierungen (1-2 Sätze)"
}
}`,
place: `
${basePrompt}
Erstelle einen Ort mit folgender JSON-Struktur:
{
"title": "Name des Ortes",
"summary": "Kurze Beschreibung",
"tags": ["typ", "stimmung"],
"content": {
"appearance": "Detaillierte Beschreibung des Ortes",
"lore": "Geschichte und Bedeutung",
"capabilities": "Was ist hier möglich?",
"constraints": "Gefahren und Einschränkungen",
"state_text": "Aktueller Zustand",
"secrets": "Verborgene Aspekte"
}
}`,
object: `
${basePrompt}
Erstelle ein Objekt/Gegenstand mit folgender JSON-Struktur:
{
"title": "Name des Objekts",
"summary": "Kurze Beschreibung",
"tags": ["typ", "seltenheit"],
"content": {
"appearance": "Aussehen und Material",
"lore": "Herkunft und Geschichte",
"capabilities": "Eigenschaften und Fähigkeiten",
"constraints": "Einschränkungen und Nachteile",
"state_text": "Aktueller Zustand und Aufbewahrungsort"
}
}`,
story: `
${basePrompt}
Erstelle eine Story mit folgender JSON-Struktur:
{
"title": "Kurzer, packender Titel",
"summary": "Zusammenfassung in 1-2 Sätzen",
"tags": ["genre", "stimmung", "max 3 tags"],
"content": {
"lore": "## Szenen-Titel\\n\\nStory-Text mit @charaktername direkt im Text...",
"references": "cast: @charaktere\\nplaces: @orte\\nobjects: @gegenstände",
"prompt_guidelines": "Erzählstil für spätere Generierungen"
}
}
STORY-REGELN:
1. Verwende Markdown: ## für Überschriften, **fett**, *kursiv*
2. Schreibe mindestens 30% Dialoge: "Text", sagte @charaktername.
3. Maximal 500 Wörter, fokussiere auf EINE Szene
4. Schreibe IMMER @slug-name DIREKT im Text, niemals Platzhalter!`,
};
let fullPrompt = kindPrompts[kind];
if (context) {
// World context with details - aber NICHT für neue Welten!
// Neue Welten sollen unabhängig von bestehenden Welten sein
if (kind !== 'world') {
if (context.worldData) {
fullPrompt += `\n\n🌍 WELT-KONTEXT: "${context.worldData.title}"`;
if (context.worldData.summary) {
fullPrompt += `\nZusammenfassung: ${context.worldData.summary}`;
}
if (context.worldData.content?.appearance) {
fullPrompt += `\nErscheinung: ${context.worldData.content.appearance}`;
}
fullPrompt += `\n\nWICHTIG: Alle generierten Inhalte MÜSSEN konsistent mit dieser Welt-Beschreibung sein!`;
} else if (context.world) {
fullPrompt += `\n\nDie Inhalte sollen zur Welt "${context.world}" passen.`;
}
}
if (context.selectedCharacters?.length) {
fullPrompt += `\n\n👥 CHARAKTERE IN DIESER STORY:`;
context.selectedCharacters.forEach((char: any) => {
fullPrompt += `\n\n${char.name} (@${char.slug})`;
if (char.summary) fullPrompt += `\n• ${char.summary}`;
if (char.voice_style) fullPrompt += `\n• Sprechstil: ${char.voice_style}`;
if (char.motivations) fullPrompt += `\n• Motivation: ${char.motivations}`;
});
fullPrompt += `\n\n⚠ KRITISCH: Verwende EXAKT diese @-Slugs im Text:`;
context.selectedCharacters.forEach((c: any) => {
fullPrompt += `\n• @${c.slug} für ${c.name}`;
});
fullPrompt += `\n\nSchreibe @${context.selectedCharacters[0].slug} statt "${context.selectedCharacters[0].name}"`;
fullPrompt += `\nNiemals Platzhalter, immer @slug-name direkt!`;
}
if (context.selectedPlace) {
const place = context.selectedPlace;
fullPrompt += `\n\n📍 AUSGEWÄHLTER ORT für diese Story:`;
fullPrompt += `\n━━━ ${place.name} (@${place.slug}) ━━━`;
if (place.summary) fullPrompt += `\n📝 Zusammenfassung: ${place.summary}`;
if (place.appearance) fullPrompt += `\n🎨 Erscheinung: ${place.appearance}`;
if (place.capabilities) fullPrompt += `\n✨ Besonderheiten: ${place.capabilities}`;
if (place.constraints) fullPrompt += `\n⚠ Gefahren/Einschränkungen: ${place.constraints}`;
if (place.secrets) fullPrompt += `\n🔒 Geheimnisse: ${place.secrets}`;
fullPrompt += `\n\n⚠ PFLICHT: Die Story MUSS an diesem Ort spielen! Nutze die Ortsbeschreibung für Atmosphäre und Setting.`;
}
if (context.existingCharacters?.length) {
fullPrompt += `\n\nExistierende Charaktere: ${context.existingCharacters.join(', ')}`;
}
if (context.existingPlaces?.length) {
fullPrompt += `\n\nExistierende Orte: ${context.existingPlaces.join(', ')}`;
}
if (context.existingObjects?.length) {
fullPrompt += `\n\nExistierende Objekte: ${context.existingObjects.join(', ')}`;
}
}
return fullPrompt;
}
export async function enhanceContent(
existingContent: Partial<ContentData>,
kind: NodeKind,
instruction: string
): Promise<Partial<ContentData>> {
aiLogger.info('Enhancing content', {
kind,
instructionLength: instruction.length,
});
const timer = aiLogger.startTimer('enhanceContent');
const systemPrompt = `Du bist ein kreativer Assistent für Worldbuilding.
Verbessere oder erweitere den gegebenen Content basierend auf den Anweisungen.
Behalte den existierenden Stil und Ton bei.
Antworte NUR mit dem verbesserten Content-Objekt im JSON-Format.`;
try {
const params = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{
role: 'user' as const,
content: `Existierender Content:\n${JSON.stringify(existingContent, null, 2)}\n\nAnweisung: ${instruction}`,
},
],
// temperature: 1 ist default für GPT-4o-mini
response_format: { type: 'json_object' as const },
};
aiLogger.apiRequest('OpenAI', 'enhanceContent', params);
const completion = await openai.chat.completions.create(params);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'enhanceContent', completion, duration);
const result = JSON.parse(completion.choices[0].message.content || '{}');
aiLogger.info('Content enhanced successfully', { duration });
return result;
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'enhanceContent', error, duration);
throw error;
}
}
export async function generateSuggestions(
field: keyof ContentData,
context: {
kind: NodeKind;
title?: string;
existingContent?: Partial<ContentData>;
}
): Promise<string[]> {
const prompts: Record<string, string> = {
appearance: 'Generiere 3 kurze Vorschläge für das Aussehen',
lore: 'Generiere 3 Ideen für die Hintergrundgeschichte',
capabilities: 'Generiere 3 Vorschläge für Fähigkeiten',
motivations: 'Generiere 3 mögliche Motivationen',
secrets: 'Generiere 3 interessante Geheimnisse',
};
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{
role: 'system' as const,
content:
'Generiere kreative Vorschläge. Antworte mit einem JSON-Array von 3 kurzen Strings.',
},
{
role: 'user' as const,
content: `${prompts[field] || 'Generiere 3 Vorschläge'} für ${context.title || 'dieses Element'}`,
},
],
// temperature: 1 ist default für GPT-4o-mini
response_format: { type: 'json_object' as const },
max_completion_tokens: 200,
});
const result = JSON.parse(completion.choices[0].message.content || '{"suggestions":[]}');
return result.suggestions || [];
}
export async function translateToImagePrompt(
germanDescription: string,
kind: NodeKind,
title: string,
style: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration' = 'fantasy'
): Promise<string> {
const timer = aiLogger.startTimer('translateToImagePrompt');
const systemPrompt = `Du bist ein Experte für KI-Bildgenerierung. Übersetze deutsche Beschreibungen in optimierte englische Prompts für Bildgenerierungs-KIs wie Flux.
Regeln:
- Übersetze präzise ins Englische
- Optimiere für Bildgenerierung (visuelle Details, Komposition, Beleuchtung)
- Keine deutschen Wörter im Ergebnis
- Fokus auf visuell beschreibbare Elemente
- Nutze Fachbegriffe für Bildqualität (sharp focus, detailed, professional, etc.)
- Antworte nur mit dem englischen Prompt, kein JSON oder zusätzlicher Text`;
const kindContext = {
character: 'Focus on character portrait, facial features, clothing, pose, expression',
place: 'Focus on environment, landscape, architecture, atmosphere, lighting',
object: 'Focus on item details, materials, textures, product shot composition',
world: 'Focus on epic scale, panoramic view, diverse landscapes, world building',
story: 'Focus on dramatic scene, narrative moment, cinematic composition',
};
const styleContext = {
realistic: 'photorealistic style',
fantasy: 'fantasy art style with magical elements',
anime: 'anime art style with vibrant colors',
'concept-art': 'professional concept art style',
illustration: 'detailed illustration style',
};
try {
const params = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{
role: 'user' as const,
content: `Title: ${title}\nKind: ${kind} (${kindContext[kind]})\nStyle: ${style} (${styleContext[style]})\n\nGerman description to translate:\n${germanDescription}`,
},
],
max_completion_tokens: 300,
};
aiLogger.apiRequest('OpenAI', 'translateToImagePrompt', params);
const completion = await openai.chat.completions.create(params);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'translateToImagePrompt', completion, duration);
const englishPrompt = completion.choices[0].message.content?.trim();
if (!englishPrompt) {
throw new Error('No translation received from API');
}
aiLogger.info('German description translated to English image prompt', {
originalLength: germanDescription.length,
translatedLength: englishPrompt.length,
duration,
});
console.log('✅ Translation successful:', {
original: germanDescription.substring(0, 50) + '...',
translated: englishPrompt.substring(0, 50) + '...',
});
return englishPrompt;
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'translateToImagePrompt', error, duration);
console.error('❌ Translation error details:', {
error: error instanceof Error ? error.message : error,
model: 'gpt-5-mini',
germanText: germanDescription.substring(0, 100) + '...',
});
// Fallback: return original text if translation fails
aiLogger.warn('Translation failed, using original text', { error });
return germanDescription;
}
}

View file

@ -0,0 +1,195 @@
import Replicate from 'replicate';
import { REPLICATE_API_TOKEN } from '$env/static/private';
import type { NodeKind } from '$lib/types/content';
// Prüfe ob Token vorhanden
if (!REPLICATE_API_TOKEN) {
console.error('REPLICATE_API_TOKEN ist nicht definiert. Bitte in .env eintragen.');
}
const replicate = new Replicate({
auth: REPLICATE_API_TOKEN || '',
});
interface ImageGenerationOptions {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: {
world?: string;
appearance?: string;
atmosphere?: string;
};
aspectRatio?: string;
}
export async function generateImageWithFlux(options: ImageGenerationOptions): Promise<{
imageUrl: string;
prompt: string;
}> {
const { kind, title, description, style = 'fantasy', context, aspectRatio = '1:1' } = options;
// Prüfe Token nochmals
if (!REPLICATE_API_TOKEN) {
throw new Error('REPLICATE_API_TOKEN nicht konfiguriert. Bitte Token in .env Datei eintragen.');
}
const prompt = buildImagePrompt(kind, title, description, style, context);
try {
console.log('Generating image with Flux Schnell, prompt:', prompt);
console.log('Using aspect ratio:', aspectRatio, 'for kind:', kind);
// Verwende die Standard run() API ohne Stream
const output = await replicate.run('black-forest-labs/flux-schnell', {
input: {
prompt: prompt,
num_outputs: 1,
aspect_ratio: aspectRatio,
output_format: 'webp',
output_quality: 80,
},
});
console.log('Flux Raw Output Type:', typeof output);
console.log('Flux Raw Output:', output);
// Verarbeite das Output - Type als unknown, da Replicate verschiedene Formate zurückgibt
let imageUrl: string = '';
const result = output as unknown;
// Wenn es ein Array mit ReadableStreams ist
if (Array.isArray(result) && result.length > 0) {
const firstItem = result[0];
// Wenn es ein ReadableStream ist, konvertiere zu Base64
if (
firstItem instanceof ReadableStream ||
(firstItem && typeof firstItem === 'object' && 'locked' in firstItem)
) {
console.log('Verarbeite ReadableStream mit Binärdaten...');
// Prüfe ob Stream bereits gelesen wurde
if (firstItem.locked || (firstItem as any).state === 'closed') {
console.error('Stream ist bereits geschlossen oder gesperrt');
throw new Error('Stream konnte nicht gelesen werden');
}
const reader = firstItem.getReader();
const chunks = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Kombiniere alle Chunks zu einem Uint8Array
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const combinedArray = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combinedArray.set(chunk, offset);
offset += chunk.length;
}
// Konvertiere zu Base64
// Verwende Buffer wenn verfügbar (Node.js), sonst btoa (Browser)
let base64String = '';
if (typeof Buffer !== 'undefined') {
// Node.js Umgebung
base64String = Buffer.from(combinedArray).toString('base64');
} else {
// Browser Umgebung (falls jemals direkt verwendet)
const binaryString = Array.from(combinedArray)
.map((byte) => String.fromCharCode(byte))
.join('');
base64String = btoa(binaryString);
}
// Erstelle Data URL (WebP Format basierend auf den Einstellungen)
imageUrl = `data:image/webp;base64,${base64String}`;
console.log('Bild als Base64 Data URL konvertiert');
} catch (streamError) {
console.error('Fehler beim Lesen des Streams:', streamError);
throw new Error('Stream konnte nicht verarbeitet werden');
} finally {
reader.releaseLock();
}
}
// Wenn es bereits eine URL ist
else if (typeof firstItem === 'string' && firstItem.startsWith('http')) {
imageUrl = firstItem;
}
}
// Wenn es direkt ein String ist
else if (typeof result === 'string' && result.startsWith('http')) {
imageUrl = result;
}
if (!imageUrl) {
console.error('Konnte keine URL extrahieren aus:', output);
throw new Error('Keine gültige Bild-URL von Flux erhalten');
}
console.log('Flux finale Bild-URL:', imageUrl);
return {
imageUrl,
prompt,
};
} catch (error: any) {
console.error('Flux Schnell Fehler:', error);
// Gebe detaillierten Fehler zurück
throw new Error(`Bildgenerierung fehlgeschlagen: ${error.message || 'Unbekannter Fehler'}`);
}
}
function buildImagePrompt(
kind: NodeKind,
title: string,
description?: string,
style: string = 'fantasy',
context?: any
): string {
const styleDescriptions = {
realistic: 'photorealistic, highly detailed, professional photography, 8k resolution',
fantasy:
'fantasy art style, magical atmosphere, detailed digital illustration, artstation quality',
anime: 'anime art style, vibrant colors, expressive, studio ghibli inspired',
'concept-art': 'concept art, professional digital painting, atmospheric, cinematic lighting',
illustration: 'detailed illustration, artistic, hand-drawn quality, storybook style',
};
const kindPrompts: Record<NodeKind, string> = {
character: `Character portrait of ${title}. ${description || ''} ${context?.appearance || ''}. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Detailed face, expressive eyes, professional character design`,
place: `Environment concept art of ${title}. ${description || ''} ${context?.atmosphere || ''}. Wide shot, establishing view. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Epic landscape, atmospheric perspective`,
object: `Item design of ${title}. ${description || ''} Centered composition, clear details. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Product shot, clean background, professional presentation`,
world: `World map or panoramic view of ${title}. ${description || ''} Epic scale, diverse landscapes. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Bird's eye view, detailed geography`,
story: `Key scene illustration from "${title}". ${description || ''} Dramatic composition, narrative moment. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Dynamic action, emotional impact`,
};
let fullPrompt = kindPrompts[kind];
if (context?.world) {
fullPrompt += ` Set in the world of ${context.world}.`;
}
// Flux-spezifische Optimierungen
fullPrompt +=
' Masterpiece, best quality, ultra-detailed, sharp focus. No watermarks, no text, no logos.';
// Flux Prompt-Limit
if (fullPrompt.length > 1000) {
fullPrompt = fullPrompt.substring(0, 1000) + '...';
}
return fullPrompt;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,166 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface Props {
field: string;
kind: NodeKind;
value: string;
onUpdate: (value: string) => void;
placeholder?: string;
rows?: number;
label: string;
}
let {
field,
kind,
value = $bindable(),
onUpdate,
placeholder,
rows = 3,
label,
}: Props = $props();
let generating = $state(false);
let showSuggestions = $state(false);
let suggestions = $state<string[]>([]);
async function generateSuggestions() {
if (generating) return;
generating = true;
showSuggestions = false;
try {
const response = await fetch('/api/ai/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
field,
context: { kind, existingContent: { [field]: value } },
}),
});
if (response.ok) {
const data = await response.json();
suggestions = data.suggestions || [];
showSuggestions = suggestions.length > 0;
}
} catch (err) {
console.error('Failed to generate suggestions:', err);
} finally {
generating = false;
}
}
async function enhanceContent() {
if (generating || !value.trim()) return;
generating = true;
try {
const response = await fetch('/api/ai/enhance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: { [field]: value },
kind,
instruction: `Verbessere und erweitere dieses ${label} Feld. Behalte den Kern bei, aber füge Details und Tiefe hinzu.`,
}),
});
if (response.ok) {
const data = await response.json();
if (data.content?.[field]) {
value = data.content[field];
onUpdate(value);
}
}
} catch (err) {
console.error('Failed to enhance content:', err);
} finally {
generating = false;
}
}
function applySuggestion(suggestion: string) {
value = value ? `${value}\n\n${suggestion}` : suggestion;
onUpdate(value);
showSuggestions = false;
}
</script>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label for={field} class="block text-sm font-medium text-slate-700">
{label}
</label>
<div class="flex space-x-1">
<button
type="button"
onclick={generateSuggestions}
disabled={generating}
title="Vorschläge generieren"
class="p-1 text-violet-600 hover:text-violet-500 disabled:opacity-50"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</button>
{#if value}
<button
type="button"
onclick={enhanceContent}
disabled={generating}
title="Mit KI verbessern"
class="p-1 text-violet-600 hover:text-violet-500 disabled:opacity-50"
>
<svg class="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>
</button>
{/if}
</div>
</div>
<textarea
id={field}
bind:value
oninput={() => onUpdate(value)}
{rows}
{placeholder}
disabled={generating}
class="mt-1 block 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 showSuggestions && suggestions.length > 0}
<div class="mt-2 rounded-md bg-violet-50 p-3">
<p class="mb-2 text-xs font-medium text-violet-900">KI-Vorschläge:</p>
<div class="space-y-2">
{#each suggestions as suggestion}
<button
type="button"
onclick={() => applySuggestion(suggestion)}
class="block w-full rounded border border-violet-200 bg-white p-2 text-left text-sm hover:bg-violet-100"
>
{suggestion}
</button>
{/each}
</div>
</div>
{/if}
{#if generating}
<p class="text-xs text-slate-500">KI arbeitet...</p>
{/if}
</div>

View file

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

View file

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

View file

@ -0,0 +1,343 @@
<script lang="ts">
import type { NodeKind, PromptTemplate } from '$lib/types/content';
import PromptTemplateSelector from './PromptTemplateSelector.svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
kind: NodeKind;
onGenerated: (data: any, prompt: string) => void;
context?: any;
selectedCharacters?: string[];
selectedPlace?: string | null;
placeholder?: string;
}
let { kind, onGenerated, context, selectedCharacters, selectedPlace, placeholder }: Props =
$props();
let prompt = $state('');
let generating = $state(false);
let error = $state<string | null>(null);
let showSaveTemplateDialog = $state(false);
let templateTitle = $state('');
let templateDescription = $state('');
const defaultPlaceholders: Record<NodeKind, string> = {
character:
'Z.B. "Ein weiser alter Magier mit einem dunklen Geheimnis" oder "Eine mutige Kriegerin aus dem Norden"',
world:
'Z.B. "Eine düstere Cyberpunk-Welt mit magischen Elementen" oder "Ein friedliches Königreich am Meer"',
place:
'Z.B. "Ein mysteriöser Wald, in dem die Zeit anders verläuft" oder "Eine schwimmende Stadt in den Wolken"',
object:
'Z.B. "Ein Amulett, das seinem Träger besondere Kräfte verleiht" oder "Ein verfluchtes Schwert"',
story:
'Z.B. "Eine Heldenreise, bei der ungleiche Gefährten zusammenfinden" oder "Ein Krimi in einer magischen Stadt"',
};
function handleTemplateSelect(template: PromptTemplate | null) {
if (template) {
let appliedPrompt = template.prompt_template;
// Variablen ersetzen
if ($currentWorld) {
appliedPrompt = appliedPrompt.replace(/{world_name}/g, $currentWorld.title);
}
prompt = appliedPrompt;
}
}
// Load character details for AI context
let characterDetails = $state<any[]>([]);
let placeDetails = $state<any | null>(null);
async function loadCharacterDetails() {
if (!selectedCharacters || selectedCharacters.length === 0) {
characterDetails = [];
return;
}
try {
const details = await Promise.all(
selectedCharacters.map(async (slug) => {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
return await response.json();
}
return null;
})
);
characterDetails = details.filter(Boolean);
} catch (err) {
console.error('Failed to load character details:', err);
}
}
async function loadPlaceDetails() {
if (!selectedPlace) {
placeDetails = null;
return;
}
try {
const response = await fetch(`/api/nodes/${selectedPlace}`);
if (response.ok) {
placeDetails = await response.json();
}
} catch (err) {
console.error('Failed to load place details:', err);
}
}
$effect(() => {
loadCharacterDetails();
loadPlaceDetails();
});
async function handleGenerate() {
if (!prompt.trim() || generating) return;
generating = true;
error = null;
// Start loading indicator für kompletten Prozess
loadingStore.startCompleteCreation(kind);
// Build enhanced context with character and place details
// Bei Welt-Erstellung: Keinen worldData Context mitschicken!
let enhancedContext =
kind === 'world' ? { ...context, worldData: undefined, world: undefined } : { ...context };
if (characterDetails.length > 0) {
enhancedContext.selectedCharacters = characterDetails.map((char) => ({
name: char.title,
slug: char.slug,
summary: char.summary,
appearance: char.content?.appearance,
voice_style: char.content?.voice_style,
motivations: char.content?.motivations,
capabilities: char.content?.capabilities,
}));
}
if (placeDetails) {
enhancedContext.selectedPlace = {
name: placeDetails.title,
slug: placeDetails.slug,
summary: placeDetails.summary,
appearance: placeDetails.content?.appearance,
capabilities: placeDetails.content?.capabilities,
constraints: placeDetails.content?.constraints,
secrets: placeDetails.content?.secrets,
};
}
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
prompt,
context: enhancedContext,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Generierung fehlgeschlagen');
}
const result = await response.json();
// Wechsel zum nächsten Schritt (Erstellen)
loadingStore.nextStep('KI-Generierung abgeschlossen');
onGenerated(result, prompt); // Prompt mitgeben für Speicherung
prompt = '';
// Loading wird in NodeForm fortgesetzt
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
loadingStore.setError(error || 'Generierung fehlgeschlagen');
} finally {
generating = false;
}
}
async function saveAsTemplate() {
if (!templateTitle.trim() || !prompt.trim()) return;
try {
const response = await fetch('/api/prompt-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
title: templateTitle,
prompt_template: prompt,
description: templateDescription,
world_slug: $currentWorld?.slug,
is_public: false,
}),
});
if (response.ok) {
showSaveTemplateDialog = false;
templateTitle = '';
templateDescription = '';
}
} catch (err) {
console.error('Failed to save template:', err);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
}
</script>
<div class="space-y-4">
<!-- Template Selector with Save Button -->
<div class="flex items-end gap-3">
<div class="flex-1">
<PromptTemplateSelector {kind} onSelect={handleTemplateSelect} />
</div>
<button
type="button"
onclick={() => (showSaveTemplateDialog = true)}
disabled={!prompt.trim()}
class="border-theme-border-default rounded border bg-theme-surface px-3 py-1.5 text-sm font-medium text-theme-text-primary transition-colors hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
title="Aktuellen Prompt als Vorlage speichern"
>
Als Vorlage speichern
</button>
</div>
<!-- Prompt Input -->
<div class="relative">
<label for="ai-prompt" class="mb-2 block text-sm font-medium text-theme-text-primary">
<span class="inline-flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-theme-primary-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>
KI-Prompt
</span>
</label>
<div class="relative">
<textarea
id="ai-prompt"
bind:value={prompt}
onkeydown={handleKeydown}
disabled={generating}
rows="3"
placeholder={placeholder || defaultPlaceholders[kind]}
class="block w-full resize-none rounded border border-theme-border-default bg-theme-surface pr-20 text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50"
></textarea>
<button
type="button"
onclick={handleGenerate}
disabled={generating || !prompt.trim()}
class="absolute bottom-1.5 right-1.5 inline-flex items-center rounded px-3 py-1.5 text-sm font-medium text-white {generating
? 'bg-orange-600'
: 'bg-theme-primary-600 hover:bg-theme-primary-700'} transition-colors focus:outline-none focus:ring-2 focus:ring-theme-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? 'Generiert...' : 'Generieren'}
</button>
</div>
<p class="mt-2 text-xs text-theme-text-secondary">
Beschreibe was du erstellen möchtest und drücke Enter oder klicke auf Generieren.
</p>
</div>
{#if error}
<div class="flex items-center rounded border border-theme-error bg-theme-error/10 p-2">
<svg class="mr-1 h-4 w-4 text-red-500" 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>
<p class="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
{/if}
{#if showSaveTemplateDialog}
<div class="border-theme-border-default rounded border bg-theme-surface p-4 shadow-sm">
<h4 class="mb-3 text-sm font-medium text-theme-text-primary">Prompt als Vorlage speichern</h4>
<div class="space-y-3">
<div>
<label
for="template-title"
class="mb-1 block text-xs font-medium text-theme-text-primary"
>
Name der Vorlage *
</label>
<input
id="template-title"
type="text"
bind:value={templateTitle}
placeholder="z.B. Cyberpunk-Welt mit Magie"
class="block w-full rounded border border-theme-border-default bg-theme-surface text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500"
/>
</div>
<div>
<label for="template-desc" class="mb-1 block text-xs font-medium text-theme-text-primary">
Beschreibung (optional)
</label>
<textarea
id="template-desc"
bind:value={templateDescription}
placeholder="Wofür ist diese Vorlage gedacht?"
rows="2"
class="block w-full resize-none rounded border border-theme-border-default bg-theme-surface text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500"
></textarea>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-theme-text-primary">
Zu speichernder Prompt:
</label>
<div
class="rounded border border-theme-border-subtle bg-theme-surface p-2 text-sm text-theme-text-secondary"
>
{prompt}
</div>
</div>
<div class="flex justify-end space-x-2 pt-2">
<button
type="button"
onclick={() => {
showSaveTemplateDialog = false;
templateTitle = '';
templateDescription = '';
}}
class="border-theme-border-default rounded border bg-theme-surface px-3 py-1.5 text-sm font-medium text-theme-text-primary transition-colors hover:bg-theme-interactive-hover"
>
Abbrechen
</button>
<button
type="button"
onclick={saveAsTemplate}
disabled={!templateTitle.trim()}
class="rounded bg-theme-primary-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-theme-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,112 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
worldSlug: string;
selectedCharacters: string[];
onSelectionChange: (selected: string[]) => void;
}
let { worldSlug, selectedCharacters, onSelectionChange }: Props = $props();
let characters = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadCharacters() {
if (!worldSlug) return;
try {
const response = await fetch(`/api/nodes?kind=character&world_slug=${worldSlug}`);
if (!response.ok) throw new Error('Failed to load characters');
characters = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Laden der Charaktere';
} finally {
loading = false;
}
}
function toggleCharacter(characterSlug: string) {
const newSelection = selectedCharacters.includes(characterSlug)
? selectedCharacters.filter((slug) => slug !== characterSlug)
: [...selectedCharacters, characterSlug];
onSelectionChange(newSelection);
}
$effect(() => {
loadCharacters();
});
</script>
<div>
<label class="block text-sm font-medium text-theme-text-primary mb-3">
Charaktere auswählen
</label>
{#if loading}
<div class="text-sm text-theme-text-secondary">Lade Charaktere...</div>
{:else if error}
<div class="text-sm text-theme-error">
{error}
</div>
{:else if characters.length === 0}
<div class="text-sm text-theme-text-secondary">
Keine Charaktere in dieser Welt gefunden.
<a
href="/worlds/{worldSlug}/characters/new"
class="text-theme-primary-600 hover:text-theme-primary-500"
>
Ersten Charakter erstellen
</a>
</div>
{:else}
<div
class="space-y-2 max-h-60 overflow-y-auto border border-theme-border-default rounded-md p-3"
>
{#each characters as character}
<label
class="flex items-center space-x-3 cursor-pointer hover:bg-theme-elevated p-2 rounded"
>
<input
type="checkbox"
checked={selectedCharacters.includes(character.slug)}
onchange={() => toggleCharacter(character.slug)}
class="rounded border-theme-border-default text-theme-primary-600 focus:ring-theme-primary-500"
/>
<div class="flex-1">
<div class="flex items-center space-x-2">
{#if character.image_url}
<img
src={character.image_url}
alt={character.title}
class="w-8 h-8 rounded-full object-cover"
/>
{/if}
<div>
<div class="text-sm font-medium text-theme-text-primary">
{character.title}
</div>
<div class="text-xs text-theme-text-secondary">
@{character.slug}
</div>
</div>
</div>
{#if character.summary}
<div class="text-xs text-theme-text-secondary mt-1 line-clamp-1">
{character.summary}
</div>
{/if}
</div>
</label>
{/each}
</div>
{#if selectedCharacters.length > 0}
<div class="mt-2 text-xs text-theme-text-secondary">
Ausgewählt: {selectedCharacters.map((slug) => `@${slug}`).join(', ')}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { slide } from 'svelte/transition';
interface Props {
title?: string;
initiallyOpen?: boolean;
hasContent?: boolean;
children?: any;
}
let {
title = 'Weitere Optionen',
initiallyOpen = false,
hasContent = false,
children,
}: Props = $props();
// Automatically open if there's content or manually requested
let isOpen = $state(initiallyOpen || hasContent);
// Update isOpen when hasContent changes
$effect(() => {
if (hasContent && !isOpen) {
isOpen = true;
}
});
function toggle() {
isOpen = !isOpen;
}
</script>
<div class="border-t pt-6">
<button
type="button"
onclick={toggle}
class="-m-2 flex w-full items-center justify-between rounded-md p-2 text-left transition-colors hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
>
<h2 class="text-lg font-medium text-theme-text-primary">{title}</h2>
<svg
class="h-5 w-5 text-theme-text-secondary transition-transform {isOpen ? 'rotate-180' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div transition:slide={{ duration: 200 }} class="mt-4 space-y-4">
{@render children?.()}
</div>
{/if}
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,301 @@
<script lang="ts">
import { loadingStore } from '$lib/stores/loadingStore';
import { fade, fly, scale } from 'svelte/transition';
import { cubicOut, elasticOut } from 'svelte/easing';
// Props - optional message for simple loading states
interface Props {
message?: string;
}
let { message }: Props = $props();
// Reactive state from store
let loading = $derived($loadingStore);
let minimized = $state(false);
let toastMode = $state(false);
// Use store title or fallback to message prop
let displayTitle = $derived(loading.title || message || 'Laden...');
// Calculate overall progress
let progress = $derived(() => {
if (!loading.steps.length) return 0;
const completed = loading.steps.filter((s) => s.status === 'completed').length;
return (completed / loading.steps.length) * 100;
});
// Show overlay if store is loading OR if message prop is provided
let showOverlay = $derived(loading.isLoading || !!message);
</script>
{#if showOverlay}
<!-- Overlay -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-theme-overlay backdrop-blur-sm"
transition:fade={{ duration: 200 }}
>
<!-- Main Container with Glassmorphism (responsive) -->
<div
class="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-theme-border-subtle bg-theme-surface/95 shadow-2xl backdrop-blur-md sm:mx-auto {minimized
? 'max-w-xs'
: ''}"
transition:fly={{ y: 50, duration: 300, easing: cubicOut }}
>
<!-- Header with gradient -->
<div
class="relative overflow-hidden border-b border-theme-border-subtle bg-gradient-to-r from-theme-primary-500/10 to-theme-primary-600/10 px-6 py-4"
>
<!-- Animated background pattern -->
<div class="absolute inset-0 opacity-30">
<div
class="absolute -left-4 -top-4 h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-400 to-transparent blur-2xl"
></div>
<div
class="absolute -right-4 -bottom-4 h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-600 to-transparent blur-2xl animation-delay-1000"
></div>
</div>
<div class="relative flex items-center justify-between">
<h2 class="text-xl font-semibold text-theme-text-primary">
{displayTitle}
</h2>
<!-- Minimize button -->
<button
onclick={() => (minimized = !minimized)}
class="rounded-lg p-1 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
title={minimized ? 'Maximieren' : 'Minimieren'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if minimized}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
{/if}
</svg>
</button>
</div>
<!-- Progress bar -->
<div class="relative mt-3 h-1.5 overflow-hidden rounded-full bg-theme-border-default">
<div
class="absolute left-0 top-0 h-full bg-gradient-to-r from-theme-primary-500 to-theme-primary-600 transition-all duration-500 ease-out"
style="width: {progress()}%"
>
<!-- Shimmer effect -->
<div
class="absolute inset-0 animate-shimmer bg-gradient-to-r from-transparent via-white/30 to-transparent"
></div>
</div>
</div>
<!-- Progress percentage -->
{#if progress() > 0}
<div class="mt-2 flex items-center justify-between text-xs">
<span class="text-theme-text-secondary">{Math.round(progress())}% abgeschlossen</span>
{#if loading.estimatedTime && loading.estimatedTime > Date.now()}
<span class="text-theme-text-secondary">
~{Math.max(1, Math.ceil((loading.estimatedTime - Date.now()) / 1000))}s verbleibend
</span>
{/if}
</div>
{/if}
</div>
{#if !minimized}
<!-- Steps Container -->
<div class="px-6 py-4" transition:scale={{ duration: 200, easing: cubicOut }}>
<div class="space-y-3">
{#each loading.steps as step, index}
<div class="flex items-start space-x-3" style="animation-delay: {index * 50}ms">
<!-- Step indicator with animations -->
<div class="relative mt-0.5 flex-shrink-0">
{#if step.status === 'completed'}
<div
class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-theme-success to-theme-success-dark shadow-lg shadow-theme-success/30"
transition:scale={{ duration: 400, easing: elasticOut }}
>
<svg
class="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
transition:scale={{ duration: 600, delay: 100, easing: elasticOut }}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
{:else if step.status === 'active'}
<div class="relative flex h-7 w-7 items-center justify-center">
<!-- Outer ring animation -->
<div
class="absolute inset-0 animate-ping rounded-full bg-theme-primary-400 opacity-30"
></div>
<!-- Inner spinning ring -->
<div
class="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-theme-primary-500 border-r-theme-primary-600"
></div>
<!-- Center dot -->
<div
class="relative h-3 w-3 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-500 to-theme-primary-600"
></div>
</div>
{:else if step.status === 'error'}
<div
class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-theme-error to-theme-error-dark shadow-lg shadow-theme-error/30"
transition:scale={{ duration: 400, easing: elasticOut }}
>
<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="3"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{:else}
<div
class="h-7 w-7 rounded-full border-2 border-theme-border-default bg-theme-elevated"
></div>
{/if}
</div>
<!-- Step content -->
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium transition-colors duration-200 {step.status ===
'active'
? 'text-theme-text-primary'
: step.status === 'completed'
? 'text-theme-text-secondary'
: 'text-theme-text-tertiary'}"
>
{step.label}
</p>
{#if step.message}
<p
class="mt-1 text-xs text-theme-text-tertiary"
transition:fade={{ duration: 200 }}
>
{step.message}
</p>
{/if}
</div>
</div>
<!-- Animated connection line -->
{#if index < loading.steps.length - 1}
<div class="relative ml-3.5 h-4">
<div
class="absolute left-0 w-px bg-theme-border-default {step.status === 'completed'
? 'bg-gradient-to-b from-theme-success to-theme-success/50'
: ''}"
style="height: 100%; transform-origin: top; transition: all 0.3s ease-out;"
></div>
{#if step.status === 'active'}
<div
class="absolute left-0 w-px animate-flow bg-gradient-to-b from-theme-primary-500 to-transparent"
style="height: 100%;"
></div>
{/if}
</div>
{/if}
{/each}
</div>
<!-- Error message -->
{#if loading.error}
<div
class="mt-4 rounded-lg bg-theme-error/10 border border-theme-error/20 p-3"
transition:scale={{ duration: 200 }}
>
<p class="text-sm text-theme-error">{loading.error}</p>
</div>
{/if}
<!-- Fun Fact -->
{#if loading.funFact && !loading.error}
<div
class="mt-4 rounded-lg bg-gradient-to-r from-theme-primary-500/5 to-theme-primary-600/5 border border-theme-border-subtle p-3"
transition:fade={{ duration: 300 }}
>
<p class="text-xs italic text-theme-text-secondary">
{loading.funFact}
</p>
</div>
{/if}
</div>
<!-- Footer actions -->
{#if loading.error}
<div class="rounded-b-2xl bg-theme-elevated px-6 py-3">
<button
onclick={() => loadingStore.reset()}
class="w-full rounded-lg border border-theme-border-default bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
Schließen
</button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<style>
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
@keyframes flow {
0% {
opacity: 0;
transform: translateY(-100%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
.animate-flow {
animation: flow 1.5s ease-in-out infinite;
}
.animation-delay-1000 {
animation-delay: 1s;
}
</style>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
node: ContentNode;
href: string;
}
let { node, href }: Props = $props();
// Get the primary image from attachments or fallback to image_url
let primaryImage = $derived(node.image_url);
// For character portraits, use object-top to show faces
let imageObjectPosition = $derived(node.kind === 'character' ? 'object-top' : 'object-center');
// Get aspect ratio class based on node kind
let aspectClass = $derived(() => {
switch (node.kind) {
case 'world':
case 'place':
return 'aspect-[21/9]'; // Ultrawide for worlds and places
case 'character':
return 'aspect-[9/16]'; // Portrait for characters
case 'object':
case 'story':
default:
return 'aspect-square'; // Square for objects and stories
}
});
</script>
<a
{href}
class="overflow-hidden rounded-lg bg-theme-surface shadow transition-all hover:shadow-md hover:-translate-y-0.5"
>
{#if primaryImage}
<div class="{aspectClass} w-full bg-theme-elevated">
<img
src={primaryImage}
alt={node.title}
class="h-full w-full object-cover {imageObjectPosition}"
loading="lazy"
/>
</div>
{/if}
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium text-theme-text-primary">{node.title}</h3>
{#if node.summary}
<p class="mt-1 line-clamp-2 text-sm text-theme-text-secondary">{node.summary}</p>
{/if}
<div class="mt-3 flex items-center justify-between">
<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.tags && node.tags.length > 0}
<div class="flex space-x-1">
{#each node.tags.slice(0, 2) 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}
</div>
{/if}
</div>
</div>
</a>

View file

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

View file

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

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import NodeCard from './NodeCard.svelte';
interface Props {
kind: NodeKind;
kindLabel: string;
kindLabelPlural: string;
description: string;
user: any;
}
let { kind, kindLabel, kindLabelPlural, description, user }: Props = $props();
let nodes = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadNodes() {
try {
const response = await fetch(`/api/nodes?kind=${kind}`);
if (!response.ok) throw new Error(`Failed to load ${kindLabelPlural}`);
nodes = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
$effect(() => {
loadNodes();
});
function getNodeUrl(node: ContentNode): string {
const baseUrls: Record<NodeKind, string> = {
world: '/worlds',
character: '/characters',
object: '/objects',
place: '/places',
story: '/stories',
};
return `${baseUrls[node.kind]}/${node.slug}`;
}
</script>
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-text-primary">{kindLabelPlural}</h1>
<p class="mt-1 text-sm text-theme-text-secondary">{description}</p>
</div>
{#if user}
<div class="mt-4 sm:mt-0">
<a
href="/{kind === 'world'
? 'worlds'
: kind === 'character'
? 'characters'
: kind === 'place'
? 'places'
: kind === 'object'
? 'objects'
: 'stories'}/new"
class="inline-flex items-center 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"
>
{kindLabel} erstellen
</a>
</div>
{/if}
</div>
{#if loading}
<div class="py-12 text-center">
<p class="text-theme-text-secondary">Lade {kindLabelPlural}...</p>
</div>
{:else if error}
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{:else if nodes.length === 0}
<div class="rounded-lg bg-theme-surface py-12 text-center shadow">
<p class="text-theme-text-secondary">Noch keine {kindLabelPlural} vorhanden</p>
{#if user}
<a
href="/{kind === 'world'
? 'worlds'
: kind === 'character'
? 'characters'
: kind === 'place'
? 'places'
: kind === 'object'
? 'objects'
: 'stories'}/new"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-theme-primary-600 hover:text-theme-primary-500"
>
Erste {kindLabel} erstellen
</a>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each nodes as node}
<NodeCard {node} href={getNodeUrl(node)} />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,506 @@
<script lang="ts">
import type {
CharacterMemory,
ShortTermMemory,
MediumTermMemory,
LongTermMemory,
NodeKind,
} from '$lib/types/content';
import { parseReferences } from '$lib/utils/markdown';
interface Props {
nodeSlug: string;
nodeKind: NodeKind;
memory: CharacterMemory | null;
editable?: boolean;
onMemoryUpdate?: (memory: CharacterMemory) => void;
}
let {
nodeSlug,
nodeKind,
memory: initialMemory,
editable = false,
onMemoryUpdate,
}: Props = $props();
// Make memory reactive with $state
let memory = $state<CharacterMemory | null>(initialMemory);
// Update memory when prop changes
$effect(() => {
memory = initialMemory;
});
let activeTab = $state<'short' | 'medium' | 'long'>('short');
let showAddMemory = $state(false);
let newMemoryContent = $state('');
let newMemoryTier = $state<'short' | 'medium' | 'long'>('short');
let newMemoryImportance = $state(5);
let addingMemory = $state(false);
// Format timestamp for display
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) {
return 'Gerade eben';
} else if (diffHours < 24) {
return `Vor ${diffHours} Stunde${diffHours === 1 ? '' : 'n'}`;
} else if (diffDays < 7) {
return `Vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `Vor ${weeks} Woche${weeks === 1 ? '' : 'n'}`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `Vor ${months} Monat${months === 1 ? '' : 'en'}`;
} else {
const years = Math.floor(diffDays / 365);
return `Vor ${years} Jahr${years === 1 ? '' : 'en'}`;
}
}
// Get importance color
function getImportanceColor(importance: number): string {
if (importance >= 8) return 'text-theme-error';
if (importance >= 6) return 'text-theme-warning';
if (importance >= 4) return 'text-theme-primary-600';
return 'text-theme-text-secondary';
}
// Get category emoji
function getCategoryEmoji(category?: string): string {
switch (category) {
case 'trauma':
return '😰';
case 'triumph':
return '🏆';
case 'relationship':
return '💕';
case 'skill':
return '📚';
case 'secret':
return '🤫';
default:
return '💭';
}
}
// Add new memory
async function addMemory() {
if (!newMemoryContent.trim()) return;
addingMemory = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newMemoryContent,
tier: newMemoryTier,
importance: newMemoryImportance,
}),
});
if (response.ok) {
// Reload memory
await loadMemory();
// Reset form
newMemoryContent = '';
newMemoryImportance = 5;
showAddMemory = false;
}
} catch (err) {
console.error('Failed to add memory:', err);
} finally {
addingMemory = false;
}
}
// Delete memory
async function deleteMemory(memoryId: string) {
if (!confirm('Diese Erinnerung wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory/${memoryId}`, {
method: 'DELETE',
});
if (response.ok) {
await loadMemory();
}
} catch (err) {
console.error('Failed to delete memory:', err);
}
}
// Process memories (age them)
async function processMemories() {
if (!confirm('Erinnerungen altern lassen? Kurzzeit → Mittelzeit → Langzeit')) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
await loadMemory();
}
} catch (err) {
console.error('Failed to process memories:', err);
}
}
// Load memory from API
async function loadMemory() {
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory`);
if (response.ok) {
const data = await response.json();
memory = data;
if (onMemoryUpdate) {
onMemoryUpdate(data);
}
}
} catch (err) {
console.error('Failed to load memory:', err);
}
}
// Count memories per tier
let shortTermCount = $derived(memory?.short_term_memory?.length || 0);
let mediumTermCount = $derived(memory?.medium_term_memory?.length || 0);
let longTermCount = $derived(memory?.long_term_memory?.length || 0);
// Get tab labels based on node kind
function getTabLabels() {
switch (nodeKind) {
case 'world':
return { short: 'Aktuelle', medium: 'Jüngere', long: 'Historie' };
case 'place':
return { short: 'Kürzlich', medium: 'Ereignisse', long: 'Geschichte' };
case 'object':
return { short: 'Aktuell', medium: 'Verlauf', long: 'Ursprung' };
default:
return { short: 'Kurzzeit', medium: 'Mittelzeit', long: 'Langzeit' };
}
}
const tabLabels = getTabLabels();
// Load memory on mount if not provided
$effect(() => {
if (!memory && nodeSlug) {
loadMemory();
}
});
</script>
<div class="memory-container">
<!-- Tab Navigation -->
<div class="flex items-center justify-between mb-4">
<div class="flex space-x-1 bg-theme-elevated rounded-lg p-1">
<button
onclick={() => (activeTab = 'short')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'short'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.short} ({shortTermCount})
</button>
<button
onclick={() => (activeTab = 'medium')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'medium'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.medium} ({mediumTermCount})
</button>
<button
onclick={() => (activeTab = 'long')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'long'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.long} ({longTermCount})
</button>
</div>
{#if editable}
<div class="flex space-x-2">
<button
onclick={() => (showAddMemory = !showAddMemory)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded text-sm hover:bg-theme-primary-700"
>
+ Neue Erinnerung
</button>
<button
onclick={processMemories}
class="px-3 py-1.5 border border-theme-border-default rounded text-sm hover:bg-theme-elevated"
>
⏰ Altern lassen
</button>
</div>
{/if}
</div>
<!-- Add Memory Form -->
{#if showAddMemory && editable}
<div class="mb-4 p-4 bg-theme-elevated rounded-lg">
<div class="space-y-3">
<div>
<label
for="memory-content"
class="block text-sm font-medium text-theme-text-primary mb-1"
>
Erinnerung
</label>
<textarea
id="memory-content"
bind:value={newMemoryContent}
placeholder="Was ist passiert?"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface text-theme-text-primary"
rows="3"
></textarea>
</div>
<div class="flex space-x-3">
<div class="flex-1">
<label for="memory-tier" class="block text-sm font-medium text-theme-text-primary mb-1">
Ebene
</label>
<select
id="memory-tier"
bind:value={newMemoryTier}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface text-theme-text-primary"
>
<option value="short">Kurzzeit (1-3 Tage)</option>
<option value="medium">Mittelzeit (1-3 Monate)</option>
<option value="long">Langzeit (Permanent)</option>
</select>
</div>
<div class="flex-1">
<label
for="memory-importance"
class="block text-sm font-medium text-theme-text-primary mb-1"
>
Wichtigkeit: {newMemoryImportance}
</label>
<input
id="memory-importance"
type="range"
min="1"
max="10"
bind:value={newMemoryImportance}
class="w-full"
/>
</div>
</div>
<div class="flex justify-end space-x-2">
<button
onclick={() => (showAddMemory = false)}
class="px-3 py-1.5 border border-theme-border-default rounded text-sm hover:bg-theme-elevated"
>
Abbrechen
</button>
<button
onclick={addMemory}
disabled={addingMemory || !newMemoryContent.trim()}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded text-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{addingMemory ? 'Wird gespeichert...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/if}
<!-- Memory Content -->
<div class="space-y-3">
{#if !memory}
<div class="text-center py-8 text-theme-text-secondary">Keine Erinnerungen vorhanden</div>
{:else if activeTab === 'short'}
{#if shortTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Kurzzeiterinnerungen (letzte 3 Tage)
</div>
{:else}
{#each memory.short_term_memory as mem}
<div class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.importance)} text-xs">
{mem.importance}/10
</span>
{#if editable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary">
{@html parseReferences(mem.content)}
</div>
{#if mem.location || mem.involved?.length}
<div class="mt-2 flex flex-wrap gap-2">
{#if mem.location}
<span class="text-xs bg-theme-surface px-2 py-0.5 rounded">
📍 {mem.location}
</span>
{/if}
{#each mem.involved || [] as person}
<span class="text-xs bg-theme-surface px-2 py-0.5 rounded">
👤 {person}
</span>
{/each}
</div>
{/if}
{#if mem.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each mem.tags as tag}
<span class="text-xs text-theme-primary-600">
{tag}
</span>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{:else if activeTab === 'medium'}
{#if mediumTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Mittelzeiterinnerungen (1 Woche - 3 Monate)
</div>
{:else}
{#each memory.medium_term_memory as mem}
<div class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.importance)} text-xs">
{mem.importance}/10
</span>
{#if editable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary">
{@html parseReferences(mem.content)}
</div>
{#if mem.context}
<div class="mt-2 text-xs text-theme-text-secondary italic">
Kontext: {mem.context}
</div>
{/if}
{#if mem.original_details}
<details class="mt-2">
<summary class="text-xs text-theme-primary-600 cursor-pointer">
Details anzeigen
</summary>
<div class="mt-1 text-sm text-theme-text-secondary">
{mem.original_details}
</div>
</details>
{/if}
</div>
{/each}
{/if}
{:else if activeTab === 'long'}
{#if longTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Langzeiterinnerungen (permanent)
</div>
{:else}
{#each memory.long_term_memory as mem}
<div
class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors border-l-4 {mem.category ===
'trauma'
? 'border-theme-error'
: mem.category === 'triumph'
? 'border-theme-success'
: 'border-theme-primary-600'}"
>
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<span class="text-lg">
{getCategoryEmoji(mem.category)}
</span>
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.emotional_weight)} text-xs">
💪 {mem.emotional_weight}/10
</span>
{#if editable && !mem.immutable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary font-medium">
{@html parseReferences(mem.content)}
</div>
{#if mem.effects}
<div class="mt-2 text-sm text-theme-warning">
Auswirkung: {mem.effects}
</div>
{/if}
{#if mem.triggers?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each mem.triggers as trigger}
<span class="text-xs bg-theme-error/10 text-theme-error px-2 py-0.5 rounded">
{trigger}
</span>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{/if}
</div>
<!-- Memory Traits -->
{#if memory?.memory_traits}
<div class="mt-6 p-4 bg-theme-elevated rounded-lg">
<h4 class="text-sm font-medium text-theme-text-primary mb-2">Gedächtniseigenschaften</h4>
<div class="space-y-1 text-xs text-theme-text-secondary">
<div>Qualität: {memory.memory_traits.memory_quality}</div>
{#if memory.memory_traits.trauma_filter}
<div>⚠️ Trauma-Filter aktiv</div>
{/if}
{#if memory.memory_traits.selective_memory?.length}
<div>Selektiv: {memory.memory_traits.selective_memory.join(', ')}</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
worldSlug: string;
selectedPlace: string | null;
onSelectionChange: (selected: string | null) => void;
}
let { worldSlug, selectedPlace, onSelectionChange }: Props = $props();
let places = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadPlaces() {
if (!worldSlug) return;
try {
const response = await fetch(`/api/nodes?kind=place&world_slug=${worldSlug}`);
if (!response.ok) throw new Error('Failed to load places');
places = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Laden der Orte';
} finally {
loading = false;
}
}
function selectPlace(placeSlug: string) {
// Toggle selection - if already selected, deselect
if (selectedPlace === placeSlug) {
onSelectionChange(null);
} else {
onSelectionChange(placeSlug);
}
}
$effect(() => {
loadPlaces();
});
</script>
<div>
<label class="block text-sm font-medium text-theme-text-primary mb-3">
📍 Ort auswählen (optional)
</label>
{#if loading}
<div class="text-sm text-theme-text-secondary">Lade Orte...</div>
{:else if error}
<div class="text-sm text-theme-error">
{error}
</div>
{:else if places.length === 0}
<div class="text-sm text-theme-text-secondary">
Keine Orte in dieser Welt gefunden.
<a
href="/worlds/{worldSlug}/places/new"
class="text-theme-primary-600 hover:text-theme-primary-500"
>
Ersten Ort erstellen
</a>
</div>
{:else}
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-theme-border-default rounded-md p-3"
>
{#each places as place}
<button
type="button"
onclick={() => selectPlace(place.slug)}
class="flex items-start space-x-3 p-2 rounded text-left transition-colors
{selectedPlace === place.slug
? 'bg-theme-primary-100 dark:bg-theme-primary-900/30 border-2 border-theme-primary-500'
: 'hover:bg-theme-elevated border-2 border-transparent'}"
>
{#if place.image_url}
<img
src={place.image_url}
alt={place.title}
class="w-12 h-12 rounded object-cover flex-shrink-0"
/>
{:else}
<div
class="w-12 h-12 rounded bg-theme-elevated flex items-center justify-center flex-shrink-0"
>
📍
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-theme-text-primary">
{place.title}
</div>
<div class="text-xs text-theme-text-secondary">
@{place.slug}
</div>
{#if place.summary}
<div class="text-xs text-theme-text-secondary mt-1 line-clamp-2">
{place.summary}
</div>
{/if}
</div>
</button>
{/each}
</div>
{#if selectedPlace}
<div class="mt-2 text-xs text-theme-text-secondary">
Ausgewählt: @{selectedPlace}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,288 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
let {
node,
class: className = '',
}: {
node: ContentNode;
class?: string;
} = $props();
let showFullPrompt = $state(false);
let showFullContext = $state(false);
function formatDate(dateString: string | undefined) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async function reusePrompt() {
// Kopiere den Prompt in die Zwischenablage
if (node.generation_prompt) {
await navigator.clipboard.writeText(node.generation_prompt);
alert('Prompt wurde in die Zwischenablage kopiert!');
}
}
async function copyFullContext() {
if (node.generation_context) {
const contextText = `USER PROMPT:\n${node.generation_context.userPrompt}\n\nSYSTEM PROMPT:\n${node.generation_context.systemPrompt}`;
await navigator.clipboard.writeText(contextText);
alert('Vollständiger Kontext wurde in die Zwischenablage kopiert!');
}
}
</script>
{#if node.generation_prompt}
<div class={className}>
<div class="space-y-6">
<!-- Main Generation Info -->
<div>
<h3 class="mb-4 flex items-center text-lg font-medium text-theme-text-primary">
<svg
class="mr-2 h-5 w-5 text-theme-primary-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>
KI-Generiert
</h3>
<div class="rounded-lg bg-theme-elevated p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-theme-text-primary mb-2">Verwendeter Prompt:</p>
<p class="text-sm text-theme-text-secondary {showFullPrompt ? '' : 'line-clamp-3'}">
{node.generation_prompt}
</p>
{#if node.generation_prompt.length > 150}
<button
type="button"
onclick={() => (showFullPrompt = !showFullPrompt)}
class="mt-2 text-xs font-medium text-theme-primary-600 hover:text-theme-primary-500"
>
{showFullPrompt ? 'Weniger anzeigen' : 'Mehr anzeigen'}
</button>
{/if}
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-theme-text-secondary">
{#if node.generation_model}
<span class="flex items-center">
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{node.generation_model}
</span>
{/if}
{#if node.generation_date}
<span class="flex items-center">
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{formatDate(node.generation_date)}
</span>
{/if}
</div>
</div>
<div class="ml-4 flex flex-col gap-2">
<button
type="button"
onclick={reusePrompt}
class="inline-flex items-center rounded-md border border-theme-border-default bg-theme-surface px-3 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
User-Prompt
</button>
{#if node.generation_context}
<button
type="button"
onclick={() => (showFullContext = !showFullContext)}
class="inline-flex items-center rounded-md border border-theme-primary-300 bg-theme-primary-100/50 px-3 py-1.5 text-xs font-medium text-theme-primary-700 hover:bg-theme-primary-200/50 dark:border-theme-primary-600 dark:bg-theme-primary-900/30 dark:text-theme-primary-400 dark:hover:bg-theme-primary-900/50"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{showFullContext ? 'Debug ausblenden' : 'Debug-Infos'}
</button>
{/if}
</div>
</div>
</div>
</div>
<!-- Debug Context Display -->
{#if showFullContext && node.generation_context}
<div class="border-t border-theme-border-subtle pt-6">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-medium text-theme-text-primary">
Debug: Vollständiger LLM-Input
</h4>
<button
type="button"
onclick={copyFullContext}
class="inline-flex items-center rounded border border-theme-border-default bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-interactive-hover"
>
Volltext kopieren
</button>
</div>
<div class="space-y-4">
<!-- User Prompt -->
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🟢 User-Prompt</h5>
<div
class="rounded bg-green-500/10 dark:bg-green-400/10 p-3 text-xs text-theme-text-secondary font-mono whitespace-pre-wrap"
>
{node.generation_context.userPrompt}
</div>
</div>
<!-- World Context -->
{#if node.generation_context.worldDetails}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🌍 Welt-Kontext</h5>
<div class="rounded bg-theme-primary-100/50 dark:bg-theme-primary-900/30 p-3">
<div
class="text-xs font-medium text-theme-primary-800 dark:text-theme-primary-400"
>
{node.generation_context.worldDetails.title}
</div>
{#if node.generation_context.worldDetails.summary}
<div class="text-xs text-theme-primary-600 dark:text-theme-primary-500 mt-1">
📝 {node.generation_context.worldDetails.summary}
</div>
{/if}
{#if node.generation_context.worldDetails.appearance}
<div class="text-xs text-theme-primary-600 dark:text-theme-primary-500 mt-1">
🎨 {node.generation_context.worldDetails.appearance}
</div>
{/if}
</div>
</div>
{/if}
<!-- Selected Characters -->
{#if node.generation_context.selectedCharacters && node.generation_context.selectedCharacters.length > 0}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">
👥 Ausgewählte Charaktere
</h5>
<div class="rounded bg-blue-500/10 dark:bg-blue-400/10 p-3">
{#each node.generation_context.selectedCharacters as char}
<div class="mb-2 last:mb-0">
<div class="text-xs font-medium text-blue-700 dark:text-blue-400">
@{char.slug} ({char.name})
</div>
{#if char.summary}<div class="text-xs text-blue-600 dark:text-blue-500">
📄 {char.summary}
</div>{/if}
{#if char.appearance}<div class="text-xs text-blue-600 dark:text-blue-500">
👀 {char.appearance}
</div>{/if}
{#if char.motivations}<div class="text-xs text-blue-600 dark:text-blue-500">
🎯 {char.motivations}
</div>{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Selected Place -->
{#if node.generation_context.selectedPlace}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">
📍 Ausgewählter Ort
</h5>
<div class="rounded bg-amber-500/10 dark:bg-amber-400/10 p-3">
<div class="text-xs font-medium text-amber-700 dark:text-amber-400">
@{node.generation_context.selectedPlace.slug} ({node.generation_context
.selectedPlace.name})
</div>
{#if node.generation_context.selectedPlace.summary}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
📄 {node.generation_context.selectedPlace.summary}
</div>{/if}
{#if node.generation_context.selectedPlace.appearance}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
🎨 {node.generation_context.selectedPlace.appearance}
</div>{/if}
{#if node.generation_context.selectedPlace.capabilities}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
{node.generation_context.selectedPlace.capabilities}
</div>{/if}
{#if node.generation_context.selectedPlace.constraints}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
⚠️ {node.generation_context.selectedPlace.constraints}
</div>{/if}
</div>
</div>
{/if}
<!-- System Prompt -->
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🔧 System-Prompt</h5>
<div
class="rounded bg-theme-elevated p-3 text-xs text-theme-text-secondary font-mono whitespace-pre-wrap max-h-64 overflow-y-auto"
>
{node.generation_context.systemPrompt}
</div>
</div>
<!-- Metadata -->
<div class="flex items-center space-x-4 text-xs text-theme-text-secondary">
<span>🤖 {node.generation_context.model}</span>
<span>{formatDate(node.generation_context.timestamp)}</span>
{#if node.generation_context.worldContext}
<span>🌍 {node.generation_context.worldContext}</span>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { PromptTemplate, NodeKind } from '$lib/types/content';
import { currentWorld } from '$lib/stores/worldContext';
let {
kind,
onSelect,
class: className = '',
}: {
kind: NodeKind;
onSelect: (template: PromptTemplate | null) => void;
class?: string;
} = $props();
let templates = $state<PromptTemplate[]>([]);
let loading = $state(true);
let selectedTemplateId = $state<string>('');
async function loadTemplates() {
try {
// Lade eigene und öffentliche Templates
const response = await fetch(`/api/prompt-templates?kind=${kind}`);
if (response.ok) {
templates = await response.json();
}
} catch (err) {
console.error('Failed to load templates:', err);
} finally {
loading = false;
}
}
function handleSelection(e: Event) {
const select = e.target as HTMLSelectElement;
const template = templates.find((t) => t.id === select.value);
onSelect(template || null);
}
function applyVariables(template: string): string {
let result = template;
if ($currentWorld) {
result = result.replace(/{world_name}/g, $currentWorld.title);
}
return result;
}
$effect(() => {
loadTemplates();
});
</script>
<div class="prompt-template-selector {className}">
<label for="template-select" class="mb-1 block text-sm font-medium text-theme-text-primary">
Prompt-Vorlage (optional)
</label>
<select
id="template-select"
bind:value={selectedTemplateId}
onchange={handleSelection}
disabled={loading}
class="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="">-- Keine Vorlage --</option>
{#if loading}
<option disabled>Lade Vorlagen...</option>
{:else}
<optgroup label="Meine Vorlagen">
{#each templates.filter((t) => t.owner_id) as template}
<option value={template.id}>
{template.title}
{#if template.usage_count > 0}
({template.usage_count}x verwendet)
{/if}
</option>
{/each}
</optgroup>
<optgroup label="Community-Vorlagen">
{#each templates.filter((t) => t.is_public && !t.owner_id) as template}
<option value={template.id}>
{template.title}
{#if template.usage_count > 0}
({template.usage_count}x verwendet)
{/if}
</option>
{/each}
</optgroup>
{/if}
</select>
</div>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { renderMarkdownSmart, renderMarkdown } from '$lib/utils/markdown';
import { onMount } from 'svelte';
interface Props {
text: string;
class?: string;
immediateRender?: boolean; // Sofort mit Fallback rendern
references?: string; // Optional references field from story
}
let { text, class: className = '', immediateRender = true, references }: Props = $props();
let renderedHtml = $state('');
let loading = $state(true);
async function renderContent() {
if (!text) {
renderedHtml = '';
loading = false;
return;
}
console.log('📖 SmartMarkdown: Rendering text:', text.substring(0, 300));
if (references) {
console.log('📚 Story references field:', references);
}
// Parse references to extract slugs
let context: any = undefined;
if (references && /REF_\d+/.test(text)) {
// Extract character and place slugs from references field
const lines = references.split('\n');
const characters: { slug: string }[] = [];
let place: { slug: string } | undefined;
lines.forEach((line) => {
if (line.startsWith('cast:')) {
// Extract character slugs: "cast: @finn-zahnrad, @zahnkiel"
const matches = line.matchAll(/@([\w-]+)/g);
for (const match of matches) {
characters.push({ slug: match[1] });
}
} else if (line.startsWith('places:')) {
// Extract place slug: "places: @kupferloge"
const match = line.match(/@([\w-]+)/);
if (match) {
place = { slug: match[1] };
}
}
});
if (characters.length > 0 || place) {
context = { characters, place };
console.log('📝 Extracted context for REF replacement:', context);
}
}
// Immediate render with formatted slugs if requested
if (immediateRender) {
const immediateHtml = renderMarkdown(text);
console.log('⚡ Immediate render result:', immediateHtml.substring(0, 300));
renderedHtml = immediateHtml;
}
// Then fetch real names and update
try {
const smartHtml = await renderMarkdownSmart(text, context);
console.log('✨ Smart render result:', smartHtml.substring(0, 300));
renderedHtml = smartHtml;
} catch (error) {
console.error('Failed to render with smart display:', error);
// Keep the immediate render as fallback
if (!immediateRender) {
renderedHtml = renderMarkdown(text);
}
} finally {
loading = false;
}
}
// Re-render when text changes
$effect(() => {
loading = true;
renderContent();
});
</script>
<div class="smart-markdown {className}">
{#if loading && !immediateRender}
<div class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
{:else}
{@html renderedHtml}
{/if}
</div>
<style>
:global(.smart-markdown p) {
margin-bottom: 1rem;
}
:global(.smart-markdown h2) {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
margin-top: 1.5rem;
}
:global(.smart-markdown h3) {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
margin-top: 1rem;
}
:global(.smart-markdown ul, .smart-markdown ol) {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
:global(.smart-markdown li) {
margin-bottom: 0.25rem;
}
:global(.smart-markdown blockquote) {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
:global(.smart-markdown code) {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
:global(.smart-markdown pre) {
background-color: #1f2937;
color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
:global(.smart-markdown a[data-kind='character']) {
border-bottom: 2px dotted currentColor;
text-decoration: none;
}
:global(.smart-markdown a[data-kind='place']) {
border-bottom: 1px dashed currentColor;
text-decoration: none;
}
:global(.smart-markdown a[data-kind='object']) {
border-bottom: 1px solid currentColor;
text-decoration: none;
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { theme, type ThemeName, type ThemeMode } from '$lib/themes/themeStore';
let showDropdown = $state(false);
let themes = theme.getAvailableThemes();
// Get current state reactively
let currentTheme = $state<ThemeName>('default');
let currentMode = $state<ThemeMode>('light');
// Subscribe to theme changes
theme.subscribe((state) => {
currentTheme = state.theme;
currentMode = state.mode;
});
function selectTheme(themeId: ThemeName) {
theme.setTheme(themeId);
showDropdown = false;
}
function toggleMode() {
theme.toggleMode();
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-switcher')) {
showDropdown = false;
}
}
$effect(() => {
if (showDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Theme icons mapping
const themeIcons: Record<ThemeName, string> = {
default: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
</svg>`,
forest: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>`,
ocean: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>`,
};
// Mode icons
const modeIcons: Record<ThemeMode, string> = {
light: `<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>`,
dark: `<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>`,
};
</script>
<div class="theme-switcher relative flex items-center gap-1">
<!-- Mode toggle button -->
<button
onclick={toggleMode}
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Toggle {currentMode === 'light' ? 'dark' : 'light'} mode"
title={currentMode === 'light' ? 'Zu Dark Mode wechseln' : 'Zu Light Mode wechseln'}
>
{@html modeIcons[currentMode]}
</button>
<!-- Theme selector -->
<button
onclick={() => (showDropdown = !showDropdown)}
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Theme auswählen"
aria-expanded={showDropdown}
title="Theme: {themes.find((t) => t.id === currentTheme)?.name}"
>
{@html themeIcons[currentTheme] || themeIcons.default}
</button>
{#if showDropdown}
<div
class="absolute right-0 z-50 mt-2 w-56 overflow-hidden rounded-lg border border-theme-border-subtle bg-theme-surface shadow-lg"
>
<div class="py-1">
<div
class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-theme-text-secondary"
>
Themes
</div>
{#each themes as themeOption}
<button
onclick={() => selectTheme(themeOption.id)}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-theme-text-primary transition-colors hover:bg-theme-interactive-hover
{currentTheme === themeOption.id ? 'bg-theme-interactive-active' : ''}"
>
<span class="flex-shrink-0">
{@html themeIcons[themeOption.id]}
</span>
<span class="flex-1">{themeOption.name}</span>
{#if currentTheme === themeOption.id}
<div class="flex items-center gap-1">
{@html modeIcons[currentMode]}
<svg
class="h-4 w-4 flex-shrink-0 text-theme-primary-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
</div>
<div class="border-t border-theme-border-subtle px-3 py-2">
<div class="text-xs text-theme-text-tertiary">
Aktuell: {themes.find((t) => t.id === currentTheme)?.name} ({currentMode === 'light'
? 'Hell'
: 'Dunkel'})
</div>
</div>
</div>
{/if}
</div>

View file

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

View file

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

View file

@ -0,0 +1,388 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import type {
CustomFieldSchema,
CustomFieldData,
CustomFieldDefinition,
CustomFieldTemplate,
} from '$lib/types/customFields';
import { createEmptySchema } from '$lib/types/customFields';
import FieldDefinitionEditor from './FieldDefinitionEditor.svelte';
import CustomDataForm from './CustomDataForm.svelte';
interface Props {
node?: ContentNode;
nodeSlug?: string;
nodeKind: string;
worldSlug?: string;
onSchemaChange?: (schema: CustomFieldSchema) => void;
onDataChange?: (data: CustomFieldData) => void;
}
let { node, nodeSlug, nodeKind, worldSlug, onSchemaChange, onDataChange }: Props = $props();
let activeTab = $state<'data' | 'schema' | 'templates'>('data');
let schema = $state<CustomFieldSchema>(node?.custom_schema || createEmptySchema());
let customData = $state<CustomFieldData>(node?.custom_data || {});
let isEditingSchema = $state(false);
let editingField = $state<CustomFieldDefinition | null>(null);
let showFieldEditor = $state(false);
let templates = $state<CustomFieldTemplate[]>([]);
let loadingTemplates = $state(false);
let selectedTemplate = $state<string | null>(null);
// Load templates
async function loadTemplates() {
if (loadingTemplates) return;
loadingTemplates = true;
try {
const response = await fetch(`/api/templates?applicable_to=${nodeKind}&is_public=true`);
if (response.ok) {
const data = await response.json();
templates = data.templates || [];
}
} catch (err) {
console.error('Failed to load templates:', err);
} finally {
loadingTemplates = false;
}
}
// Save schema
async function saveSchema() {
if (!nodeSlug) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/schema`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema }),
});
if (response.ok) {
isEditingSchema = false;
if (onSchemaChange) {
onSchemaChange(schema);
}
} else {
console.error('Failed to save schema');
}
} catch (err) {
console.error('Error saving schema:', err);
}
}
// Save custom data
async function saveCustomData(data: CustomFieldData) {
if (!nodeSlug) return;
customData = data;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/custom-data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (response.ok) {
if (onDataChange) {
onDataChange(data);
}
} else {
console.error('Failed to save custom data');
}
} catch (err) {
console.error('Error saving custom data:', err);
}
}
// Apply template
async function applyTemplate(templateId: string) {
const template = templates.find((t) => t.id === templateId);
if (!template) return;
// Merge template fields with existing schema
const existingKeys = schema.fields.map((f) => f.key);
const newFields = template.fields.filter((f) => !existingKeys.includes(f.key));
schema = {
...schema,
fields: [...schema.fields, ...newFields],
template_id: templateId,
template_version: template.version,
};
// Initialize data for new fields
for (const field of newFields) {
if (!(field.key in customData)) {
customData[field.key] = getDefaultValue(field);
}
}
selectedTemplate = null;
}
// Add field to schema
function addField(field: CustomFieldDefinition) {
schema = {
...schema,
fields: [...schema.fields, field],
version: (schema.version || 0) + 1,
};
showFieldEditor = false;
}
// Edit existing field
function editField(field: CustomFieldDefinition) {
schema = {
...schema,
fields: schema.fields.map((f) => (f.id === field.id ? field : f)),
version: (schema.version || 0) + 1,
};
editingField = null;
}
// Remove field from schema
function removeField(fieldId: string) {
if (confirm('Dieses Feld wirklich entfernen? Die Daten gehen verloren.')) {
schema = {
...schema,
fields: schema.fields.filter((f) => f.id !== fieldId),
version: (schema.version || 0) + 1,
};
// Remove data for this field
const field = schema.fields.find((f) => f.id === fieldId);
if (field) {
delete customData[field.key];
}
}
}
// Get default value for field
function getDefaultValue(field: CustomFieldDefinition): any {
switch (field.type) {
case 'text':
return '';
case 'number':
case 'range':
return field.config.default ?? field.config.min ?? 0;
case 'boolean':
return false;
case 'date':
return new Date().toISOString().split('T')[0];
case 'select':
return field.config.choices?.[0]?.value ?? '';
case 'multiselect':
return [];
case 'list':
return [];
case 'json':
return {};
case 'reference':
return field.config.multiple ? [] : null;
default:
return null;
}
}
// Load templates when switching to templates tab
$effect(() => {
if (activeTab === 'templates' && templates.length === 0) {
loadTemplates();
}
});
// Check if we have any fields
let hasFields = $derived(schema.fields.length > 0);
</script>
<div class="custom-fields-manager">
<!-- Tab Navigation -->
<div class="flex border-b border-theme-border-default mb-4">
<button
onclick={() => (activeTab = 'data')}
class="px-4 py-2 text-sm font-medium {activeTab === 'data'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Daten
{#if hasFields}
<span class="ml-1 text-xs bg-theme-primary-100 text-theme-primary-700 px-2 py-0.5 rounded">
{schema.fields.length}
</span>
{/if}
</button>
<button
onclick={() => (activeTab = 'schema')}
class="px-4 py-2 text-sm font-medium {activeTab === 'schema'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Felder verwalten
</button>
<button
onclick={() => (activeTab = 'templates')}
class="px-4 py-2 text-sm font-medium {activeTab === 'templates'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Vorlagen
</button>
</div>
<!-- Tab Content -->
{#if activeTab === 'data'}
{#if hasFields}
<CustomDataForm
{schema}
data={customData}
onChange={onDataChange}
onSave={nodeSlug ? saveCustomData : undefined}
/>
{:else}
<div class="text-center py-12 bg-theme-elevated rounded-lg">
<p class="text-theme-text-secondary mb-4">
Noch keine benutzerdefinierten Felder vorhanden
</p>
<button
onclick={() => (activeTab = 'schema')}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Felder hinzufügen
</button>
</div>
{/if}
{:else if activeTab === 'schema'}
<div class="space-y-4">
{#if !isEditingSchema}
<!-- Field List -->
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">Benutzerdefinierte Felder</h3>
<button
onclick={() => (showFieldEditor = true)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 text-sm"
>
+ Neues Feld
</button>
</div>
{#if hasFields}
<div class="space-y-2">
{#each schema.fields as field}
<div class="flex items-center justify-between p-3 bg-theme-elevated rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{field.label}</span>
<span class="text-xs text-theme-text-secondary">({field.key})</span>
<span class="text-xs px-2 py-0.5 bg-theme-surface rounded">
{field.type}
</span>
</div>
{#if field.description}
<p class="text-sm text-theme-text-secondary mt-1">
{field.description}
</p>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={() => (editingField = field)}
class="text-theme-primary-600 hover:text-theme-primary-700"
>
✏️
</button>
<button
onclick={() => removeField(field.id)}
class="text-theme-error hover:text-theme-error/80"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{#if nodeSlug}
<div class="flex justify-end mt-4">
<button
onclick={saveSchema}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Schema speichern
</button>
</div>
{/if}
{:else}
<p class="text-center text-theme-text-secondary py-8">Noch keine Felder definiert</p>
{/if}
{/if}
<!-- Field Editor -->
{#if showFieldEditor}
<FieldDefinitionEditor
onSave={addField}
onCancel={() => (showFieldEditor = false)}
existingKeys={schema.fields.map((f) => f.key)}
/>
{/if}
{#if editingField}
<FieldDefinitionEditor
field={editingField}
onSave={editField}
onCancel={() => (editingField = null)}
existingKeys={schema.fields.filter((f) => f.id !== editingField?.id).map((f) => f.key)}
/>
{/if}
</div>
{:else if activeTab === 'templates'}
<div class="space-y-4">
<h3 class="text-lg font-medium mb-4">Verfügbare Vorlagen</h3>
{#if loadingTemplates}
<p class="text-center text-theme-text-secondary py-8">Lade Vorlagen...</p>
{:else if templates.length === 0}
<p class="text-center text-theme-text-secondary py-8">
Keine Vorlagen für {nodeKind} verfügbar
</p>
{:else}
<div class="grid gap-4">
{#each templates as template}
<div class="p-4 bg-theme-elevated rounded-lg">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-medium">{template.name}</h4>
{#if template.description}
<p class="text-sm text-theme-text-secondary mt-1">
{template.description}
</p>
{/if}
<div class="flex gap-2 mt-2">
{#each template.tags as tag}
<span class="text-xs px-2 py-0.5 bg-theme-surface rounded">
{tag}
</span>
{/each}
</div>
<p class="text-xs text-theme-text-secondary mt-2">
{template.fields.length} Felder •
{template.usage_count} mal verwendet
</p>
</div>
<button
onclick={() => applyTemplate(template.id)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 text-sm"
>
Anwenden
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,445 @@
<script lang="ts">
import type { CustomFieldDefinition, FieldType, FieldConfig } from '$lib/types/customFields';
import { createFieldDefinition, validateFieldKey } from '$lib/types/customFields';
interface Props {
field?: CustomFieldDefinition;
onSave: (field: CustomFieldDefinition) => void;
onCancel: () => void;
existingKeys?: string[];
}
let { field, onSave, onCancel, existingKeys = [] }: Props = $props();
// Initialize form values
let editingField = $state<CustomFieldDefinition>(field || createFieldDefinition('', '', 'text'));
let keyError = $state('');
let labelError = $state('');
// Field type options
const fieldTypes: Array<{ value: FieldType; label: string; icon: string }> = [
{ value: 'text', label: 'Text', icon: '📝' },
{ value: 'number', label: 'Zahl', icon: '🔢' },
{ value: 'range', label: 'Bereich', icon: '📊' },
{ value: 'select', label: 'Auswahl', icon: '📋' },
{ value: 'multiselect', label: 'Mehrfachauswahl', icon: '☑️' },
{ value: 'boolean', label: 'Ja/Nein', icon: '✓' },
{ value: 'date', label: 'Datum', icon: '📅' },
{ value: 'formula', label: 'Formel', icon: '🧮' },
{ value: 'reference', label: 'Referenz', icon: '🔗' },
{ value: 'list', label: 'Liste', icon: '📚' },
{ value: 'json', label: 'JSON', icon: '{}' },
];
// Choice management for select/multiselect
let newChoiceLabel = $state('');
let newChoiceValue = $state('');
function addChoice() {
if (!newChoiceLabel || !newChoiceValue) return;
if (!editingField.config.choices) {
editingField.config.choices = [];
}
editingField.config.choices = [
...editingField.config.choices,
{ label: newChoiceLabel, value: newChoiceValue },
];
newChoiceLabel = '';
newChoiceValue = '';
}
function removeChoice(index: number) {
if (editingField.config.choices) {
editingField.config.choices = editingField.config.choices.filter((_, i) => i !== index);
}
}
// Validation
function validateField(): boolean {
let isValid = true;
// Validate key
if (!editingField.key) {
keyError = 'Schlüssel ist erforderlich';
isValid = false;
} else if (!validateFieldKey(editingField.key)) {
keyError =
'Schlüssel muss kleingeschrieben sein, mit Buchstaben beginnen und nur Buchstaben, Zahlen und Unterstriche enthalten';
isValid = false;
} else if (!field && existingKeys.includes(editingField.key)) {
keyError = 'Dieser Schlüssel existiert bereits';
isValid = false;
} else {
keyError = '';
}
// Validate label
if (!editingField.label) {
labelError = 'Bezeichnung ist erforderlich';
isValid = false;
} else {
labelError = '';
}
// Type-specific validation
if (editingField.type === 'select' || editingField.type === 'multiselect') {
if (!editingField.config.choices || editingField.config.choices.length === 0) {
isValid = false;
}
}
if (editingField.type === 'formula' && !editingField.config.formula) {
isValid = false;
}
return isValid;
}
function handleSave() {
if (validateField()) {
onSave(editingField);
}
}
// Update config when type changes
$effect(() => {
// Reset config when type changes
if (!field) {
editingField.config = getDefaultConfig(editingField.type);
}
});
function getDefaultConfig(type: FieldType): FieldConfig {
switch (type) {
case 'number':
case 'range':
return { min: 0, max: 100, default: 0 };
case 'select':
case 'multiselect':
return { choices: [] };
case 'text':
return { multiline: false, maxLength: 255 };
case 'formula':
return { formula: '', dependencies: [] };
case 'reference':
return { reference_type: 'character', multiple: false };
case 'list':
return { item_type: 'text', max_items: 10 };
default:
return {};
}
}
</script>
<div class="field-editor bg-theme-elevated rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">
{field ? 'Feld bearbeiten' : 'Neues Feld erstellen'}
</h3>
<div class="space-y-4">
<!-- Field Key -->
<div>
<label for="field-key" class="block text-sm font-medium mb-1">
Schlüssel <span class="text-theme-error">*</span>
</label>
<input
id="field-key"
type="text"
bind:value={editingField.key}
disabled={!!field}
placeholder="z.B. strength, health_points"
class="w-full px-3 py-2 border rounded-md {keyError
? 'border-theme-error'
: 'border-theme-border-default'}
bg-theme-surface disabled:opacity-50"
/>
{#if keyError}
<p class="text-sm text-theme-error mt-1">{keyError}</p>
{/if}
<p class="text-xs text-theme-text-secondary mt-1">
Eindeutiger Bezeichner für das Feld (kann nicht geändert werden)
</p>
</div>
<!-- Field Label -->
<div>
<label for="field-label" class="block text-sm font-medium mb-1">
Bezeichnung <span class="text-theme-error">*</span>
</label>
<input
id="field-label"
type="text"
bind:value={editingField.label}
placeholder="z.B. Stärke, Lebenspunkte"
class="w-full px-3 py-2 border rounded-md {labelError
? 'border-theme-error'
: 'border-theme-border-default'}
bg-theme-surface"
/>
{#if labelError}
<p class="text-sm text-theme-error mt-1">{labelError}</p>
{/if}
</div>
<!-- Field Type -->
<div>
<label for="field-type" class="block text-sm font-medium mb-1">
Typ <span class="text-theme-error">*</span>
</label>
<select
id="field-type"
bind:value={editingField.type}
disabled={!!field}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface disabled:opacity-50"
>
{#each fieldTypes as type}
<option value={type.value}>
{type.icon}
{type.label}
</option>
{/each}
</select>
</div>
<!-- Description -->
<div>
<label for="field-description" class="block text-sm font-medium mb-1"> Beschreibung </label>
<textarea
id="field-description"
bind:value={editingField.description}
placeholder="Optionale Beschreibung des Feldes"
rows="2"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
></textarea>
</div>
<!-- Category -->
<div>
<label for="field-category" class="block text-sm font-medium mb-1"> Kategorie </label>
<input
id="field-category"
type="text"
bind:value={editingField.category}
placeholder="z.B. Attribute, Ressourcen, Kampf"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<!-- Required -->
<div class="flex items-center">
<input
id="field-required"
type="checkbox"
bind:checked={editingField.required}
class="mr-2"
/>
<label for="field-required" class="text-sm"> Pflichtfeld </label>
</div>
<!-- Type-specific configuration -->
{#if editingField.type === 'number' || editingField.type === 'range'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Zahlen-Konfiguration</h4>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-sm mb-1">Min</label>
<input
type="number"
bind:value={editingField.config.min}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<div>
<label class="block text-sm mb-1">Max</label>
<input
type="number"
bind:value={editingField.config.max}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<div>
<label class="block text-sm mb-1">Standard</label>
<input
type="number"
bind:value={editingField.config.default}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
<div>
<label class="block text-sm mb-1">Einheit</label>
<input
type="text"
bind:value={editingField.config.unit}
placeholder="z.B. kg, km, %"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
{#if editingField.type === 'text'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Text-Konfiguration</h4>
<div class="flex items-center">
<input
id="text-multiline"
type="checkbox"
bind:checked={editingField.config.multiline}
class="mr-2"
/>
<label for="text-multiline" class="text-sm"> Mehrzeiliger Text </label>
</div>
<div>
<label class="block text-sm mb-1">Max. Länge</label>
<input
type="number"
bind:value={editingField.config.maxLength}
placeholder="255"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
{#if editingField.type === 'select' || editingField.type === 'multiselect'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Auswahloptionen</h4>
<!-- Add choice -->
<div class="flex gap-2">
<input
type="text"
bind:value={newChoiceLabel}
placeholder="Bezeichnung"
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
<input
type="text"
bind:value={newChoiceValue}
placeholder="Wert"
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
<button
onclick={addChoice}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Hinzufügen
</button>
</div>
<!-- Choice list -->
{#if editingField.config.choices && editingField.config.choices.length > 0}
<div class="space-y-2">
{#each editingField.config.choices as choice, i}
<div class="flex items-center justify-between p-2 bg-theme-surface rounded">
<span>{choice.label} ({choice.value})</span>
<button
onclick={() => removeChoice(i)}
class="text-theme-error hover:text-theme-error/80"
>
🗑️
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-theme-text-secondary">Noch keine Optionen hinzugefügt</p>
{/if}
</div>
{/if}
{#if editingField.type === 'formula'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Formel-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Formel</label>
<textarea
bind:value={editingField.config.formula}
placeholder="z.B. (strength + dexterity) / 2"
rows="3"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface font-mono text-sm"
></textarea>
<p class="text-xs text-theme-text-secondary mt-1">
Verwende andere Feldnamen in der Formel, z.B. strength, dexterity
</p>
</div>
</div>
{/if}
{#if editingField.type === 'reference'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Referenz-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Referenz-Typ</label>
<select
bind:value={editingField.config.reference_type}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
>
<option value="character">Charakter</option>
<option value="object">Objekt</option>
<option value="place">Ort</option>
<option value="story">Geschichte</option>
<option value="world">Welt</option>
</select>
</div>
<div class="flex items-center">
<input
id="ref-multiple"
type="checkbox"
bind:checked={editingField.config.multiple}
class="mr-2"
/>
<label for="ref-multiple" class="text-sm"> Mehrere Referenzen erlauben </label>
</div>
</div>
{/if}
{#if editingField.type === 'list'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Listen-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Element-Typ</label>
<select
bind:value={editingField.config.item_type}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
>
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="boolean">Ja/Nein</option>
<option value="date">Datum</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">Max. Elemente</label>
<input
type="number"
bind:value={editingField.config.max_items}
placeholder="10"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t">
<button
onclick={onCancel}
class="px-4 py-2 border border-theme-border-default rounded-md hover:bg-theme-elevated"
>
Abbrechen
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
{field ? 'Speichern' : 'Feld hinzufügen'}
</button>
</div>
</div>

View file

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

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,349 @@
import type {
CharacterMemory,
ShortTermMemory,
MediumTermMemory,
LongTermMemory,
MemoryEvent,
} from '$lib/types/content';
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
export class MemoryService {
/**
* Get node memory
*/
static async getMemory(nodeId: string): Promise<CharacterMemory | null> {
const { data, error } = await supabase
.from('content_nodes')
.select('memory')
.eq('id', nodeId)
.maybeSingle(); // Use maybeSingle to handle 0 or 1 rows
if (error) {
console.error('Error fetching memory:', error);
return this.getDefaultMemory();
}
// If no data or no memory field, return default memory
if (!data || !data.memory) {
return this.getDefaultMemory();
}
return data.memory;
}
/**
* Update node memory
*/
static async updateMemory(nodeId: string, memory: CharacterMemory): Promise<boolean> {
const { error } = await supabase
.from('content_nodes')
.update({
memory,
updated_at: new Date().toISOString(),
})
.eq('id', nodeId);
if (error) {
console.error('Error updating memory:', error);
return false;
}
return true;
}
/**
* Add a new memory to a node
*/
static async addMemory(
nodeId: string,
content: string,
tier: 'short' | 'medium' | 'long' = 'short',
options: {
importance?: number;
tags?: string[];
involved?: string[];
location?: string;
emotional_weight?: number;
} = {}
): Promise<boolean> {
let memory = await this.getMemory(nodeId);
// Always ensure we have a memory object
if (!memory) {
memory = this.getDefaultMemory();
}
const newMemoryId = crypto.randomUUID();
const timestamp = new Date().toISOString();
if (tier === 'short') {
const shortMemory: ShortTermMemory = {
id: newMemoryId,
timestamp,
content,
importance: options.importance || 5,
tags: options.tags || [],
involved: options.involved || [],
location: options.location,
decay_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
};
memory.short_term_memory.unshift(shortMemory);
// Keep only last 50 short-term memories
if (memory.short_term_memory.length > 50) {
memory.short_term_memory = memory.short_term_memory.slice(0, 50);
}
} else if (tier === 'medium') {
const mediumMemory: MediumTermMemory = {
id: newMemoryId,
timestamp,
content,
context: 'Manually added',
importance: options.importance || 5,
tags: options.tags || [],
involved: options.involved || [],
location: options.location,
decay_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), // 3 months
};
memory.medium_term_memory.unshift(mediumMemory);
// Keep only last 100 medium-term memories
if (memory.medium_term_memory.length > 100) {
memory.medium_term_memory = memory.medium_term_memory.slice(0, 100);
}
} else if (tier === 'long') {
const longMemory: LongTermMemory = {
id: newMemoryId,
timestamp,
content,
emotional_weight: options.emotional_weight || options.importance || 7,
category: 'manual',
triggers: options.tags,
involved: options.involved || [],
immutable: true,
};
memory.long_term_memory.unshift(longMemory);
// Keep only last 200 long-term memories
if (memory.long_term_memory.length > 200) {
memory.long_term_memory = memory.long_term_memory.slice(0, 200);
}
}
return await this.updateMemory(nodeId, memory);
}
/**
* Process and age memories
*/
static async processMemories(
nodeId: string,
currentDate?: Date
): Promise<CharacterMemory | null> {
const memory = await this.getMemory(nodeId);
if (!memory) return null;
const now = currentDate || new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const threeMonthsAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
// Process short-term memories
const agedShortTerm = memory.short_term_memory.filter(
(m) => new Date(m.timestamp) < threeDaysAgo
);
// Move important short-term to medium-term
for (const mem of agedShortTerm) {
if (mem.importance >= 3) {
const mediumMemory: MediumTermMemory = {
id: mem.id,
timestamp: mem.timestamp,
content: this.compressMemory(mem.content),
original_details: mem.content,
context: 'Aged from short-term memory',
location: mem.location,
involved: mem.involved,
tags: mem.tags,
importance: mem.importance,
decay_at: new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(),
linked_memories: [],
};
memory.medium_term_memory.push(mediumMemory);
}
}
// Remove aged memories from short-term
memory.short_term_memory = memory.short_term_memory.filter(
(m) => new Date(m.timestamp) >= threeDaysAgo
);
// Process medium-term memories
const agedMediumTerm = memory.medium_term_memory.filter(
(m) => new Date(m.timestamp) < threeMonthsAgo
);
// Move very important medium-term to long-term
for (const mem of agedMediumTerm) {
if (mem.importance >= 7 || mem.tags?.includes('#trauma') || mem.tags?.includes('#triumph')) {
const longMemory: LongTermMemory = {
id: mem.id,
timestamp: mem.timestamp,
content: this.extractCore(mem.content),
emotional_weight: mem.importance,
category: this.categorizeMemory(mem),
triggers: mem.tags,
effects: `Based on: ${mem.context}`,
involved: mem.involved,
immutable: true,
};
memory.long_term_memory.push(longMemory);
}
}
// Remove aged memories from medium-term
memory.medium_term_memory = memory.medium_term_memory.filter(
(m) => new Date(m.timestamp) >= threeMonthsAgo
);
// Update last processed time
memory.last_processed = now.toISOString();
// Save processed memory
await this.updateMemory(nodeId, memory);
return memory;
}
/**
* Delete a specific memory
*/
static async deleteMemory(nodeId: string, memoryId: string): Promise<boolean> {
const memory = await this.getMemory(nodeId);
if (!memory) return false;
// Check and remove from each tier
memory.short_term_memory = memory.short_term_memory.filter((m) => m.id !== memoryId);
memory.medium_term_memory = memory.medium_term_memory.filter((m) => m.id !== memoryId);
memory.long_term_memory = memory.long_term_memory.filter((m) => m.id !== memoryId);
return await this.updateMemory(nodeId, memory);
}
/**
* Search memories for specific content
*/
static async searchMemories(
nodeId: string,
query: string
): Promise<Array<ShortTermMemory | MediumTermMemory | LongTermMemory>> {
const memory = await this.getMemory(nodeId);
if (!memory) return [];
const results: Array<ShortTermMemory | MediumTermMemory | LongTermMemory> = [];
const searchLower = query.toLowerCase();
// Search in all tiers
for (const mem of memory.short_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.tags?.some((tag) => tag.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
for (const mem of memory.medium_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.original_details?.toLowerCase().includes(searchLower) ||
mem.tags?.some((tag) => tag.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
for (const mem of memory.long_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.triggers?.some((trigger) => trigger.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
return results;
}
/**
* Get memory events for a node
*/
static async getMemoryEvents(nodeId: string): Promise<MemoryEvent[]> {
const { data, error } = await supabase
.from('memory_events')
.select('*')
.eq('node_id', nodeId)
.order('event_timestamp', { ascending: false })
.limit(50);
if (error) {
console.error('Error fetching memory events:', error);
return [];
}
return data || [];
}
/**
* Create a memory event
*/
static async createMemoryEvent(event: Omit<MemoryEvent, 'id' | 'created_at'>): Promise<boolean> {
const { error } = await supabase.from('memory_events').insert(event);
if (error) {
console.error('Error creating memory event:', error);
return false;
}
return true;
}
// Helper methods
private static getDefaultMemory(): CharacterMemory {
return {
short_term_memory: [],
medium_term_memory: [],
long_term_memory: [],
memory_traits: {
memory_quality: 'average',
},
};
}
private static compressMemory(content: string): string {
// Simple compression - take first 200 chars
// In production, this could use AI to summarize
return content.length > 200 ? content.substring(0, 197) + '...' : content;
}
private static extractCore(content: string): string {
// Extract the most important part
// In production, this could use AI to extract key points
return content.length > 150 ? content.substring(0, 147) + '...' : content;
}
private static categorizeMemory(
memory: MediumTermMemory
): 'trauma' | 'triumph' | 'relationship' | 'skill' | 'secret' | 'manual' {
// Simple categorization based on tags
if (memory.tags?.includes('#trauma')) return 'trauma';
if (memory.tags?.includes('#triumph') || memory.tags?.includes('#success')) return 'triumph';
if (memory.tags?.includes('#relationship') || memory.involved?.length) return 'relationship';
if (memory.tags?.includes('#skill') || memory.tags?.includes('#learned')) return 'skill';
if (memory.tags?.includes('#secret')) return 'secret';
return 'manual';
}
}

View file

@ -0,0 +1,121 @@
import type { ContentNode, NodeKind, ContentData, VisibilityLevel } from '$lib/types/content';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
export interface CreateNodeRequest {
kind: NodeKind;
slug: string;
title: string;
summary?: string;
visibility: VisibilityLevel;
world_slug?: string;
tags: string[];
content: ContentData;
custom_schema?: CustomFieldSchema;
custom_data?: CustomFieldData;
image_url?: string;
generation_prompt?: string;
generation_model?: string;
generation_date?: string;
generation_context?: any;
}
export interface UpdateNodeRequest {
title?: string;
slug?: string;
summary?: string;
visibility?: VisibilityLevel;
tags?: string[];
content?: ContentData;
custom_schema?: CustomFieldSchema;
custom_data?: CustomFieldData;
image_url?: string;
}
export interface NodeFilters {
kind?: NodeKind;
world_slug?: string;
search?: string;
limit?: number;
offset?: number;
}
export class NodeService {
static async create(node: CreateNodeRequest): Promise<ContentNode> {
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Erstellen');
}
return response.json();
}
static async update(slug: string, updates: UpdateNodeRequest): Promise<ContentNode> {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Aktualisieren');
}
return response.json();
}
static async get(slug: string): Promise<ContentNode> {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Node nicht gefunden');
}
return response.json();
}
static async list(filters: NodeFilters = {}): Promise<ContentNode[]> {
const params = new URLSearchParams();
if (filters.kind) params.set('kind', filters.kind);
if (filters.world_slug) params.set('world_slug', filters.world_slug);
if (filters.search) params.set('search', filters.search);
if (filters.limit) params.set('limit', filters.limit.toString());
if (filters.offset) params.set('offset', filters.offset.toString());
const response = await fetch(`/api/nodes?${params}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Laden der Nodes');
}
return response.json();
}
static async delete(slug: string): Promise<void> {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Löschen');
}
}
static generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[äöü]/g, (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[char] || char)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}

View file

@ -0,0 +1,145 @@
import type { ContentNode } from '$lib/types/content';
export interface ReferenceData {
slug: string;
title: string;
kind: 'character' | 'place' | 'object';
image_url?: string;
}
// Cache für geladene Referenzen (Client-side)
const referenceCache = new Map<string, ReferenceData>();
const pendingRequests = new Map<string, Promise<ReferenceData | null>>();
/**
* Lädt Referenzdaten für einen Slug
*/
async function fetchReference(slug: string): Promise<ReferenceData | null> {
console.log('🔍 Fetching reference for slug:', slug);
// Check cache first
if (referenceCache.has(slug)) {
console.log('✅ Found in cache:', slug);
return referenceCache.get(slug)!;
}
// Check if request is already pending
if (pendingRequests.has(slug)) {
console.log('⏳ Request already pending for:', slug);
return pendingRequests.get(slug)!;
}
// Create new request
console.log('🌐 Making API request for:', slug);
const request = fetch(`/api/nodes/${slug}`)
.then(async (response) => {
console.log(`📡 API response for ${slug}:`, response.status);
if (!response.ok) {
console.error(`❌ Failed to fetch ${slug}:`, response.status);
return null;
}
const node: ContentNode = await response.json();
console.log(`✨ Got node data for ${slug}:`, node.title);
const reference: ReferenceData = {
slug: node.slug,
title: node.title,
kind: node.kind as 'character' | 'place' | 'object',
image_url: node.image_url,
};
// Cache the result
referenceCache.set(slug, reference);
return reference;
})
.catch((error) => {
console.error(`❌ Error fetching ${slug}:`, error);
return null;
})
.finally(() => {
pendingRequests.delete(slug);
});
pendingRequests.set(slug, request);
return request;
}
/**
* Lädt mehrere Referenzen parallel
*/
export async function fetchReferences(slugs: string[]): Promise<Map<string, ReferenceData>> {
const uniqueSlugs = [...new Set(slugs)];
const results = await Promise.all(uniqueSlugs.map((slug) => fetchReference(slug)));
const referenceMap = new Map<string, ReferenceData>();
results.forEach((data, index) => {
if (data) {
referenceMap.set(uniqueSlugs[index], data);
}
});
return referenceMap;
}
/**
* Extrahiert alle @-Referenzen aus einem Text
*/
export function extractReferences(text: string): string[] {
const matches = text.matchAll(/@([\w-]+)/g);
return [...new Set([...matches].map((m) => m[1]))];
}
/**
* Ersetzt @-Referenzen mit formatierten Links
*/
export function replaceReferences(
text: string,
references: Map<string, ReferenceData>,
options: {
showAvatar?: boolean;
linkClass?: string;
} = {}
): string {
const { showAvatar = false, linkClass = 'character-link' } = options;
// Replace each @reference with formatted link
let result = text;
for (const [slug, data] of references) {
const pattern = new RegExp(`@${slug}(?![-\\w])`, 'g');
let replacement = `<a href="/${slug}" class="${linkClass}" data-kind="${data.kind}">`;
if (showAvatar && data.image_url) {
replacement += `<img src="${data.image_url}" alt="${data.title}" class="inline-avatar" />`;
}
replacement += `${data.title}</a>`;
result = result.replace(pattern, replacement);
}
// Handle any remaining @references that weren't found
result = result.replace(/@([\w-]+)/g, (match, slug) => {
// Fallback: Zeige formatierten Slug
const displayName = slug
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<a href="/${slug}" class="${linkClass} reference-unknown">${displayName}</a>`;
});
return result;
}
/**
* Clear cache (z.B. nach Updates)
*/
export function clearReferenceCache(slug?: string) {
if (slug) {
referenceCache.delete(slug);
} else {
referenceCache.clear();
}
}

View file

@ -0,0 +1,82 @@
import type { SupabaseClient } from '@supabase/supabase-js';
const BUCKET_NAME = 'content-images';
export async function uploadImage(
supabase: SupabaseClient,
userId: string,
nodeId: string,
imageData: string | Blob,
fileName?: string
): Promise<{ url: string; path: string } | null> {
try {
// Generate unique file name
const timestamp = Date.now();
const extension = fileName?.split('.').pop() || 'png';
const filePath = `${userId}/${nodeId}/${timestamp}.${extension}`;
// Convert base64 to blob if needed
let uploadData: Blob;
if (typeof imageData === 'string') {
// Remove data URL prefix if present
const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
uploadData = new Blob([byteArray], { type: `image/${extension}` });
} else {
uploadData = imageData;
}
// Upload to Supabase Storage
const { data, error } = await supabase.storage.from(BUCKET_NAME).upload(filePath, uploadData, {
contentType: `image/${extension}`,
upsert: true,
});
if (error) {
console.error('Upload error:', error);
return null;
}
// Get public URL
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET_NAME).getPublicUrl(filePath);
return {
url: publicUrl,
path: filePath,
};
} catch (error) {
console.error('Error uploading image:', error);
return null;
}
}
export async function deleteImage(supabase: SupabaseClient, filePath: string): Promise<boolean> {
try {
const { error } = await supabase.storage.from(BUCKET_NAME).remove([filePath]);
if (error) {
console.error('Delete error:', error);
return false;
}
return true;
} catch (error) {
console.error('Error deleting image:', error);
return false;
}
}
export function getImageUrl(supabase: SupabaseClient, filePath: string): string {
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET_NAME).getPublicUrl(filePath);
return publicUrl;
}

View file

@ -0,0 +1,117 @@
import { writable } from 'svelte/store';
import type { ContentNode } from '$lib/types/content';
type AiMode = 'text' | 'image';
type ImageStyle = 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
interface AiAuthorState {
isVisible: boolean;
currentNode: ContentNode | null;
isOwner: boolean;
mode: AiMode;
imageGenerationState: {
loading: boolean;
generatedUrl: string | null;
prompt: string;
style: ImageStyle;
error: string | null;
};
}
function createAiAuthorStore() {
const { subscribe, set, update } = writable<AiAuthorState>({
isVisible: false,
currentNode: null,
isOwner: false,
mode: 'text',
imageGenerationState: {
loading: false,
generatedUrl: null,
prompt: '',
style: 'fantasy',
error: null,
},
});
return {
subscribe,
// Show bar with context
show: (node: ContentNode, isOwner: boolean) => {
update((state) => ({
...state,
isVisible: true,
currentNode: node,
isOwner,
}));
},
// Hide bar
hide: () => {
update((state) => ({
...state,
isVisible: false,
}));
},
// Toggle visibility (keeps current context)
toggle: () => {
update((state) => ({
...state,
isVisible: !state.isVisible,
}));
},
// Update context without changing visibility
setContext: (node: ContentNode, isOwner: boolean) => {
update((state) => ({
...state,
currentNode: node,
isOwner,
}));
},
// Update node after AI edit
updateNode: (updatedNode: ContentNode) => {
update((state) => ({
...state,
currentNode: updatedNode,
}));
},
// Switch mode
setMode: (mode: AiMode) => {
update((state) => ({
...state,
mode,
}));
},
// Update image generation state
setImageState: (imageState: Partial<AiAuthorState['imageGenerationState']>) => {
update((state) => ({
...state,
imageGenerationState: {
...state.imageGenerationState,
...imageState,
},
}));
},
// Reset image generation state
resetImageState: () => {
update((state) => ({
...state,
imageGenerationState: {
loading: false,
generatedUrl: null,
prompt: '',
style: 'fantasy',
error: null,
},
}));
},
};
}
export const aiAuthorStore = createAiAuthorStore();

View file

@ -0,0 +1,186 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Using Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, error: null, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,243 @@
import { writable } from 'svelte/store';
export interface LoadingStep {
id: string;
label: string;
status: 'pending' | 'active' | 'completed' | 'error';
message?: string;
duration?: number;
startTime?: number;
}
interface LoadingState {
isLoading: boolean;
title: string;
steps: LoadingStep[];
currentStep: number;
error?: string;
funFact?: string;
estimatedTime?: number;
startTime?: number;
}
// Fun Facts für Worldbuilding
const worldbuildingFacts = [
'💡 Wusstest du? Tolkien erfand Mittelerde ursprünglich für seine selbst erfundenen Sprachen.',
'🌍 Die detailliertesten fiktiven Welten haben oft ihre eigene Zeitrechnung und Kalender.',
'📚 George R.R. Martin schrieb 400.000 Wörter Hintergrundgeschichte, die nie veröffentlicht wurden.',
'🗺️ Die Karte von Westeros basiert teilweise auf einem umgedrehten Irland.',
'✨ Brandon Sanderson erstellt für jede seiner Welten eigene Magiesysteme mit festen Regeln.',
'🎭 Gute Charaktere haben oft Widersprüche - das macht sie menschlich.',
'🏰 Die besten Fantasy-Welten fühlen sich "gelebt" an, mit eigener Geschichte und Kultur.',
'🌟 J.K. Rowling plante die Harry Potter Serie 5 Jahre lang, bevor sie zu schreiben begann.',
'🐉 Drachen erscheinen in fast jeder Kultur der Welt - unabhängig voneinander.',
"📖 Terry Pratchett's Scheibenwelt hat über 40 Romane und ist eine der detailliertesten Fantasywelten.",
'🎨 Concept Art kann helfen, die Vision deiner Welt zu konkretisieren.',
'🗣️ Erfundene Sprachen (Conlangs) geben deiner Welt zusätzliche Tiefe.',
'⚔️ Die besten Konflikte entstehen aus den Motivationen der Charaktere, nicht aus dem Plot.',
'🌙 Viele Autoren träumen von ihren Welten und Charakteren.',
'🎬 Star Wars begann als 200-seitige Rohfassung, die niemand verstand.',
];
function getRandomFunFact(): string {
return worldbuildingFacts[Math.floor(Math.random() * worldbuildingFacts.length)];
}
function createLoadingStore() {
const { subscribe, set, update } = writable<LoadingState>({
isLoading: false,
title: '',
steps: [],
currentStep: -1,
});
let funFactInterval: ReturnType<typeof setInterval> | null = null;
return {
subscribe,
// Start loading with steps
start(title: string, steps: string[]) {
const now = Date.now();
set({
isLoading: true,
title,
steps: steps.map((label, index) => ({
id: `step-${index}`,
label,
status: 'pending',
startTime: undefined,
})),
currentStep: 0,
funFact: getRandomFunFact(),
startTime: now,
estimatedTime: now + steps.length * 7500, // Rough estimate: 7.5s per step for ~30s total
});
// Rotate fun facts every 5 seconds
funFactInterval = setInterval(() => {
update((state) => ({
...state,
funFact: getRandomFunFact(),
}));
}, 5000);
// Activate first step
this.nextStep();
},
// Move to next step
nextStep(message?: string) {
update((state) => {
if (!state.isLoading) return state;
const now = Date.now();
// Complete current step
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
const currentStep = state.steps[state.currentStep];
currentStep.status = 'completed';
if (message) {
currentStep.message = message;
}
// Calculate duration for completed step
if (currentStep.startTime !== undefined) {
currentStep.duration = now - currentStep.startTime;
}
}
// Move to next step
const nextIndex = state.currentStep + 1;
if (nextIndex < state.steps.length) {
state.steps[nextIndex].status = 'active';
state.steps[nextIndex].startTime = now;
state.currentStep = nextIndex;
// Update estimated time based on completed steps
const completedSteps = state.steps.filter((s) => s.status === 'completed').length;
const remainingSteps = state.steps.length - completedSteps - 1; // -1 for current active step
if (completedSteps > 0 && remainingSteps > 0) {
const totalDuration = state.steps
.filter((s) => s.duration)
.reduce((sum, s) => sum + (s.duration || 0), 0);
const avgDuration = totalDuration / completedSteps;
// Estimated time is: now + (average duration * remaining steps)
state.estimatedTime = now + avgDuration * remainingSteps;
} else {
// Default estimate: ~7.5 seconds per remaining step (for ~30s total with 4 steps)
state.estimatedTime = now + remainingSteps * 7500;
}
}
return state;
});
},
// Update current step
updateStep(message: string) {
update((state) => {
if (!state.isLoading) return state;
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
state.steps[state.currentStep].message = message;
}
return state;
});
},
// Mark step as error
setError(error: string) {
update((state) => {
if (!state.isLoading) return state;
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
state.steps[state.currentStep].status = 'error';
state.steps[state.currentStep].message = error;
}
state.error = error;
return state;
});
},
// Complete loading
complete(message?: string) {
update((state) => {
// Complete all remaining steps
state.steps = state.steps.map((step) => ({
...step,
status: step.status === 'error' ? 'error' : 'completed',
}));
if (message && state.currentStep >= 0) {
state.steps[state.currentStep].message = message;
}
return state;
});
// Clear fun fact interval
if (funFactInterval) {
clearInterval(funFactInterval);
funFactInterval = null;
}
// Hide after a short delay
setTimeout(() => {
this.reset();
}, 1500);
},
// Reset loading state
reset() {
// Clear fun fact interval
if (funFactInterval) {
clearInterval(funFactInterval);
funFactInterval = null;
}
set({
isLoading: false,
title: '',
steps: [],
currentStep: -1,
});
},
// Helper for AI generation steps
startAiGeneration(kind: string) {
const steps =
kind === 'world'
? [
'🔍 Analysiere Anforderungen...',
'🌍 Erstelle Grundlagen der Welt...',
'📚 Generiere erweiterte Details...',
'✨ Finalisiere Welt...',
]
: ['🔍 Analysiere Kontext...', '🎨 Generiere Inhalte...', '✨ Optimiere Ergebnis...'];
this.start(`${kind.charAt(0).toUpperCase() + kind.slice(1)} wird erstellt`, steps);
},
// Helper for complete creation process with image
startCompleteCreation(kind: string) {
const kindLabel =
{
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
}[kind] || kind;
const steps = [
'🤖 Generiere mit KI...',
`💾 Erstelle ${kindLabel}...`,
'🎨 Generiere Bild...',
'✅ Fertigstellung...',
];
this.start(`${kindLabel} wird komplett erstellt`, steps);
},
};
}
export const loadingStore = createLoadingStore();

View file

@ -0,0 +1,49 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import type { ContentNode } from '$lib/types/content';
// Store for the current world context
function createWorldStore() {
const STORAGE_KEY = 'worldream-current-world';
// Initialize from localStorage if available
const initialWorld =
browser && localStorage.getItem(STORAGE_KEY)
? JSON.parse(localStorage.getItem(STORAGE_KEY)!)
: null;
const { subscribe, set, update } = writable<ContentNode | null>(initialWorld);
return {
subscribe,
// Set the current world
setWorld(world: ContentNode) {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(world));
}
set(world);
},
// Clear the current world
clearWorld() {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
set(null);
},
// Get the current world (for non-reactive access)
getCurrent() {
return get({ subscribe });
},
};
}
export const currentWorld = createWorldStore();
// Derived store for world slug
export const currentWorldSlug = derived(currentWorld, ($world) => $world?.slug || null);
// Derived store for checking if we're in a world context
export const hasWorldContext = derived(currentWorld, ($world) => $world !== null);

View file

@ -0,0 +1,6 @@
import { createBrowserClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export function createClient() {
return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
}

View file

@ -0,0 +1,18 @@
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { RequestEvent } from '@sveltejs/kit';
export function createClient(event: RequestEvent) {
return createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
},
},
});
}

View file

@ -0,0 +1,120 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { themes, generateCssVariables, getTheme } from './themes.config';
export type ThemeName = keyof typeof themes;
export type ThemeMode = 'light' | 'dark';
export interface ThemeState {
theme: ThemeName;
mode: ThemeMode;
}
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>({ theme: 'default', mode: 'light' });
function applyTheme(state: ThemeState) {
if (!browser) return;
const theme = getTheme(state.theme);
if (!theme) return;
// Set data attributes
document.documentElement.setAttribute('data-theme', state.theme);
document.documentElement.setAttribute('data-mode', state.mode);
// Apply CSS variables dynamically
const cssVariables = generateCssVariables(theme, state.mode === 'dark');
const root = document.documentElement;
Object.entries(cssVariables).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
// Update dark mode class for Tailwind compatibility
if (state.mode === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Store preferences
localStorage.setItem('selectedTheme', state.theme);
localStorage.setItem('selectedMode', state.mode);
}
return {
subscribe,
init: () => {
if (!browser) return;
// Check for saved preferences
const savedTheme = localStorage.getItem('selectedTheme') as ThemeName | null;
const savedMode = localStorage.getItem('selectedMode') as ThemeMode | null;
// Check system preference as fallback
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Determine initial state
const initialState: ThemeState = {
theme: savedTheme && themes[savedTheme] ? savedTheme : 'default',
mode: savedMode || (systemPrefersDark ? 'dark' : 'light'),
};
// Apply the initial theme
set(initialState);
applyTheme(initialState);
},
setTheme: (themeName: ThemeName) => {
if (!themes[themeName]) {
console.warn(`Theme "${themeName}" not found`);
return;
}
update((current) => {
const newState = { ...current, theme: themeName };
applyTheme(newState);
return newState;
});
},
setMode: (mode: ThemeMode) => {
update((current) => {
const newState = { ...current, mode };
applyTheme(newState);
return newState;
});
},
toggleMode: () => {
update((current) => {
const newMode: ThemeMode = current.mode === 'light' ? 'dark' : 'light';
const newState: ThemeState = { ...current, mode: newMode };
applyTheme(newState);
return newState;
});
},
cycleTheme: () => {
update((current) => {
// Cycle through all available themes
const themeNames = Object.keys(themes) as ThemeName[];
const currentIndex = themeNames.indexOf(current.theme);
const nextIndex = (currentIndex + 1) % themeNames.length;
const newTheme = themeNames[nextIndex];
const newState = { ...current, theme: newTheme };
applyTheme(newState);
return newState;
});
},
getAvailableThemes: () => {
return Object.entries(themes).map(([key, theme]) => ({
id: key as ThemeName,
name: theme.name,
}));
},
getCurrentTheme: () => {
let currentState: ThemeState;
subscribe((state) => (currentState = state))();
return currentState!;
},
};
}
export const theme = createThemeStore();

View file

@ -0,0 +1,374 @@
export interface ThemeColors {
// Primary brand colors
primary: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
950: string;
};
// Background colors
background: {
base: string;
surface: string;
elevated: string;
overlay: string;
};
// Text colors
text: {
primary: string;
secondary: string;
tertiary: string;
inverse: string;
};
// Border colors
border: {
default: string;
subtle: string;
strong: string;
};
// State colors
state: {
success: string;
warning: string;
error: string;
info: string;
};
// Interactive elements
interactive: {
hover: string;
active: string;
focus: string;
disabled: string;
};
}
export interface Theme {
name: string;
light: ThemeColors;
dark: ThemeColors;
}
export const themes: Record<string, Theme> = {
default: {
name: 'Standard',
light: {
primary: {
50: 'rgb(245 243 255)', // violet-50
100: 'rgb(237 233 254)', // violet-100
200: 'rgb(221 214 254)', // violet-200
300: 'rgb(196 181 253)', // violet-300
400: 'rgb(167 139 250)', // violet-400
500: 'rgb(139 92 246)', // violet-500
600: 'rgb(124 58 237)', // violet-600
700: 'rgb(109 40 217)', // violet-700
800: 'rgb(91 33 182)', // violet-800
900: 'rgb(76 29 149)', // violet-900
950: 'rgb(46 16 101)', // violet-950
},
background: {
base: 'rgb(248 250 252)', // slate-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(15 23 42)', // slate-900
secondary: 'rgb(71 85 105)', // slate-600
tertiary: 'rgb(148 163 184)', // slate-400
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(203 213 225)', // slate-300
subtle: 'rgb(226 232 240)', // slate-200
strong: 'rgb(148 163 184)', // slate-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(248 250 252)', // slate-50
active: 'rgb(241 245 249)', // slate-100
focus: 'rgb(139 92 246)', // violet-500
disabled: 'rgb(226 232 240)', // slate-200
},
},
dark: {
primary: {
50: 'rgb(250 250 250)', // zinc-50
100: 'rgb(244 244 245)', // zinc-100
200: 'rgb(228 228 231)', // zinc-200
300: 'rgb(212 212 216)', // zinc-300
400: 'rgb(161 161 170)', // zinc-400
500: 'rgb(113 113 122)', // zinc-500
600: 'rgb(82 82 91)', // zinc-600
700: 'rgb(63 63 70)', // zinc-700
800: 'rgb(39 39 42)', // zinc-800
900: 'rgb(24 24 27)', // zinc-900
950: 'rgb(9 9 11)', // zinc-950
},
background: {
base: 'rgb(9 9 11)', // zinc-950
surface: 'rgb(39 39 42)', // zinc-800
elevated: 'rgb(63 63 70)', // zinc-700
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(244 244 245)', // zinc-100
secondary: 'rgb(161 161 170)', // zinc-400
tertiary: 'rgb(82 82 91)', // zinc-600
inverse: 'rgb(24 24 27)', // zinc-900
},
border: {
default: 'rgb(82 82 91)', // zinc-600
subtle: 'rgb(63 63 70)', // zinc-700
strong: 'rgb(113 113 122)', // zinc-500
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(63 63 70)', // zinc-700
active: 'rgb(82 82 91)', // zinc-600
focus: 'rgb(167 139 250)', // violet-400
disabled: 'rgb(39 39 42)', // zinc-800
},
},
},
forest: {
name: 'Wald',
light: {
primary: {
50: 'rgb(240 253 244)', // green-50
100: 'rgb(220 252 231)', // green-100
200: 'rgb(187 247 208)', // green-200
300: 'rgb(134 239 172)', // green-300
400: 'rgb(74 222 128)', // green-400
500: 'rgb(34 197 94)', // green-500
600: 'rgb(22 163 74)', // green-600
700: 'rgb(21 128 61)', // green-700
800: 'rgb(22 101 52)', // green-800
900: 'rgb(20 83 45)', // green-900
950: 'rgb(5 46 22)', // green-950
},
background: {
base: 'rgb(240 253 244)', // green-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(20 83 45)', // green-900
secondary: 'rgb(22 101 52)', // green-800
tertiary: 'rgb(22 163 74)', // green-600
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(134 239 172)', // green-300
subtle: 'rgb(187 247 208)', // green-200
strong: 'rgb(74 222 128)', // green-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(220 252 231)', // green-100
active: 'rgb(187 247 208)', // green-200
focus: 'rgb(34 197 94)', // green-500
disabled: 'rgb(220 252 231)', // green-100
},
},
dark: {
primary: {
50: 'rgb(240 253 244)', // green-50
100: 'rgb(220 252 231)', // green-100
200: 'rgb(187 247 208)', // green-200
300: 'rgb(134 239 172)', // green-300
400: 'rgb(74 222 128)', // green-400
500: 'rgb(34 197 94)', // green-500
600: 'rgb(22 163 74)', // green-600
700: 'rgb(21 128 61)', // green-700
800: 'rgb(22 101 52)', // green-800
900: 'rgb(20 83 45)', // green-900
950: 'rgb(5 46 22)', // green-950
},
background: {
base: 'rgb(5 46 22)', // green-950
surface: 'rgb(22 101 52)', // green-800
elevated: 'rgb(21 128 61)', // green-700
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(220 252 231)', // green-100
secondary: 'rgb(134 239 172)', // green-300
tertiary: 'rgb(74 222 128)', // green-400
inverse: 'rgb(20 83 45)', // green-900
},
border: {
default: 'rgb(21 128 61)', // green-700
subtle: 'rgb(22 101 52)', // green-800
strong: 'rgb(22 163 74)', // green-600
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(21 128 61)', // green-700
active: 'rgb(22 163 74)', // green-600
focus: 'rgb(74 222 128)', // green-400
disabled: 'rgb(22 101 52)', // green-800
},
},
},
ocean: {
name: 'Ozean',
light: {
primary: {
50: 'rgb(240 249 255)', // sky-50
100: 'rgb(224 242 254)', // sky-100
200: 'rgb(186 230 253)', // sky-200
300: 'rgb(125 211 252)', // sky-300
400: 'rgb(56 189 248)', // sky-400
500: 'rgb(14 165 233)', // sky-500
600: 'rgb(2 132 199)', // sky-600
700: 'rgb(3 105 161)', // sky-700
800: 'rgb(7 89 133)', // sky-800
900: 'rgb(12 74 110)', // sky-900
950: 'rgb(8 47 73)', // sky-950
},
background: {
base: 'rgb(240 249 255)', // sky-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(12 74 110)', // sky-900
secondary: 'rgb(7 89 133)', // sky-800
tertiary: 'rgb(3 105 161)', // sky-700
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(125 211 252)', // sky-300
subtle: 'rgb(186 230 253)', // sky-200
strong: 'rgb(56 189 248)', // sky-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(14 165 233)', // sky-500
},
interactive: {
hover: 'rgb(224 242 254)', // sky-100
active: 'rgb(186 230 253)', // sky-200
focus: 'rgb(14 165 233)', // sky-500
disabled: 'rgb(224 242 254)', // sky-100
},
},
dark: {
primary: {
50: 'rgb(240 249 255)', // sky-50
100: 'rgb(224 242 254)', // sky-100
200: 'rgb(186 230 253)', // sky-200
300: 'rgb(125 211 252)', // sky-300
400: 'rgb(56 189 248)', // sky-400
500: 'rgb(14 165 233)', // sky-500
600: 'rgb(2 132 199)', // sky-600
700: 'rgb(3 105 161)', // sky-700
800: 'rgb(7 89 133)', // sky-800
900: 'rgb(12 74 110)', // sky-900
950: 'rgb(8 47 73)', // sky-950
},
background: {
base: 'rgb(8 47 73)', // sky-950
surface: 'rgb(12 74 110)', // sky-900
elevated: 'rgb(7 89 133)', // sky-800
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(224 242 254)', // sky-100
secondary: 'rgb(125 211 252)', // sky-300
tertiary: 'rgb(56 189 248)', // sky-400
inverse: 'rgb(12 74 110)', // sky-900
},
border: {
default: 'rgb(3 105 161)', // sky-700
subtle: 'rgb(7 89 133)', // sky-800
strong: 'rgb(2 132 199)', // sky-600
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(14 165 233)', // sky-500
},
interactive: {
hover: 'rgb(7 89 133)', // sky-800
active: 'rgb(3 105 161)', // sky-700
focus: 'rgb(56 189 248)', // sky-400
disabled: 'rgb(12 74 110)', // sky-900
},
},
},
};
// Helper function to get CSS variable name
export function getCssVariableName(path: string): string {
return `--theme-${path.replace(/\./g, '-')}`;
}
// Helper function to generate CSS variables from theme
export function generateCssVariables(
theme: Theme,
isDark: boolean = false
): Record<string, string> {
const variables: Record<string, string> = {};
const colors = isDark ? theme.dark : theme.light;
// Flatten the theme colors into CSS variables
Object.entries(colors).forEach(([category, values]) => {
if (typeof values === 'object' && values !== null) {
Object.entries(values as Record<string, string>).forEach(([key, value]) => {
variables[getCssVariableName(`${category}.${key}`)] = value;
});
} else if (typeof values === 'string') {
variables[getCssVariableName(category)] = values;
}
});
return variables;
}
// Get available theme names
export function getThemeNames(): string[] {
return Object.keys(themes);
}
// Get theme by name
export function getTheme(name: string): Theme | undefined {
return themes[name];
}

View file

@ -0,0 +1,44 @@
/* Theme CSS Variables */
/* This file defines CSS variables that are dynamically updated based on the selected theme and mode */
:root {
/* Default theme (Standard Light) */
--theme-primary-50: rgb(245 243 255);
--theme-primary-100: rgb(237 233 254);
--theme-primary-200: rgb(221 214 254);
--theme-primary-300: rgb(196 181 253);
--theme-primary-400: rgb(167 139 250);
--theme-primary-500: rgb(139 92 246);
--theme-primary-600: rgb(124 58 237);
--theme-primary-700: rgb(109 40 217);
--theme-primary-800: rgb(91 33 182);
--theme-primary-900: rgb(76 29 149);
--theme-primary-950: rgb(46 16 101);
--theme-background-base: rgb(248 250 252);
--theme-background-surface: rgb(255 255 255);
--theme-background-elevated: rgb(255 255 255);
--theme-background-overlay: rgba(0 0 0 / 0.5);
--theme-text-primary: rgb(15 23 42);
--theme-text-secondary: rgb(71 85 105);
--theme-text-tertiary: rgb(148 163 184);
--theme-text-inverse: rgb(255 255 255);
--theme-border-default: rgb(203 213 225);
--theme-border-subtle: rgb(226 232 240);
--theme-border-strong: rgb(148 163 184);
--theme-state-success: rgb(34 197 94);
--theme-state-warning: rgb(251 146 60);
--theme-state-error: rgb(239 68 68);
--theme-state-info: rgb(59 130 246);
--theme-interactive-hover: rgb(248 250 252);
--theme-interactive-active: rgb(241 245 249);
--theme-interactive-focus: rgb(139 92 246);
--theme-interactive-disabled: rgb(226 232 240);
}
/* CSS variables are now dynamically updated by the theme store */
/* No need for static theme definitions here anymore */

View file

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

View file

@ -0,0 +1,269 @@
// Custom Fields System Types
export type FieldType =
| 'text' // Simple text input
| 'number' // Numeric input
| 'range' // Slider between min/max
| 'select' // Single selection dropdown
| 'multiselect' // Multiple selection
| 'boolean' // Yes/No checkbox
| 'date' // Date picker
| 'formula' // Calculated field
| 'reference' // Reference to another node
| 'list' // Array of values
| 'json'; // Structured JSON data
export interface FieldConfig {
// For number/range types
min?: number;
max?: number;
step?: number;
default?: number;
unit?: string;
// For select/multiselect
choices?: Array<{
value: string;
label: string;
color?: string;
}>;
// For text
multiline?: boolean;
maxLength?: number;
pattern?: string; // regex pattern
placeholder?: string;
// For formula
formula?: string;
dependencies?: string[]; // field keys this formula depends on
// For reference
reference_type?: 'character' | 'object' | 'place' | 'story' | 'world';
multiple?: boolean;
// For list
item_type?: FieldType;
max_items?: number;
min_items?: number;
}
export interface DisplayConfig {
width?: 'full' | 'half' | 'third' | 'quarter';
hidden?: boolean;
readonly?: boolean;
help_text?: string;
prefix?: string;
suffix?: string;
icon?: string;
color?: string;
}
export interface ValidationRule {
type: 'required' | 'min' | 'max' | 'pattern' | 'custom';
value?: any;
message?: string;
condition?: string; // condition when this rule applies
}
export interface FieldPermissions {
view?: 'owner' | 'collaborator' | 'public';
edit?: 'owner' | 'collaborator';
}
export interface CustomFieldDefinition {
id: string;
key: string; // Unique key for the field (e.g., "strength")
label: string; // Display name (e.g., "Stärke")
type: FieldType;
category?: string; // For grouping fields
description?: string;
required?: boolean;
config: FieldConfig;
display?: DisplayConfig;
validation?: ValidationRule[];
permissions?: FieldPermissions;
order?: number; // Display order
}
export interface FieldCategory {
id: string;
name: string;
description?: string;
icon?: string;
color?: string;
collapsed?: boolean; // Default collapsed state
order?: number;
}
export interface CustomFieldSchema {
version: number;
fields: CustomFieldDefinition[];
categories?: FieldCategory[];
validation_rules?: ValidationRule[];
template_id?: string; // If created from a template
template_version?: string;
}
export interface CustomFieldTemplate {
id: string;
slug: string;
name: string;
description?: string;
category: 'official' | 'community' | 'personal';
tags: string[];
applicable_to: Array<'character' | 'object' | 'place' | 'story' | 'world'>;
fields: CustomFieldDefinition[];
example_data?: Record<string, any>;
author_id?: string;
world_slug?: string;
version: string;
dependencies?: string[]; // Other template slugs
usage_count: number;
is_public: boolean;
created_at: string;
updated_at: string;
}
// Custom field data is a simple key-value object
export type CustomFieldData = Record<string, any>;
// Validation result
export interface ValidationResult {
valid: boolean;
errors: Array<{
field: string;
message: string;
rule?: string;
}>;
warnings?: Array<{
field: string;
message: string;
}>;
}
// Formula evaluation context
export interface FormulaContext {
fields: CustomFieldData;
node?: any; // Current node data
world?: any; // World context
references?: Record<string, any>; // Referenced nodes
}
// Field change event
export interface FieldChangeEvent {
field: string;
oldValue: any;
newValue: any;
timestamp: string;
triggeredBy?: string; // Which field triggered this change (for formulas)
}
// Helper type for field values
export type FieldValue<T extends FieldType> = T extends 'text'
? string
: T extends 'number'
? number
: T extends 'range'
? number
: T extends 'select'
? string
: T extends 'multiselect'
? string[]
: T extends 'boolean'
? boolean
: T extends 'date'
? string
: T extends 'formula'
? any
: T extends 'reference'
? string | string[]
: T extends 'list'
? any[]
: T extends 'json'
? any
: any;
// Schema builder helper types
export interface SchemaBuilder {
addField(field: Omit<CustomFieldDefinition, 'id'>): SchemaBuilder;
addCategory(category: FieldCategory): SchemaBuilder;
removeField(key: string): SchemaBuilder;
updateField(key: string, updates: Partial<CustomFieldDefinition>): SchemaBuilder;
reorderFields(order: string[]): SchemaBuilder;
build(): CustomFieldSchema;
}
// Template filters for browsing
export interface TemplateFilter {
category?: 'official' | 'community' | 'personal';
tags?: string[];
applicable_to?: Array<'character' | 'object' | 'place' | 'story' | 'world'>;
author_id?: string;
world_slug?: string;
search?: string;
is_public?: boolean;
sort_by?: 'usage_count' | 'created_at' | 'updated_at' | 'name';
sort_order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
// Export utility functions
export function createEmptySchema(): CustomFieldSchema {
return {
version: 1,
fields: [],
categories: [],
validation_rules: [],
};
}
export function createFieldDefinition(
key: string,
label: string,
type: FieldType,
config?: Partial<FieldConfig>
): CustomFieldDefinition {
return {
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
key,
label,
type,
config: config || {},
required: false,
};
}
export function validateFieldKey(key: string): boolean {
// Must be lowercase, alphanumeric with underscores, no spaces
return /^[a-z][a-z0-9_]*$/.test(key);
}
export function getDefaultValueForType(type: FieldType, config?: FieldConfig): any {
switch (type) {
case 'text':
return '';
case 'number':
case 'range':
return config?.default ?? config?.min ?? 0;
case 'select':
return config?.choices?.[0]?.value ?? '';
case 'multiselect':
return [];
case 'boolean':
return false;
case 'date':
return new Date().toISOString().split('T')[0];
case 'list':
return [];
case 'json':
return {};
case 'reference':
return config?.multiple ? [] : null;
case 'formula':
return null;
default:
return null;
}
}

View file

@ -0,0 +1,174 @@
// Logger utility für API-Calls und Debugging
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
class Logger {
private level: LogLevel = LogLevel.INFO;
private prefix: string;
constructor(prefix: string = 'Worldream') {
this.prefix = prefix;
// In Dev-Modus mehr loggen
if (process.env.NODE_ENV === 'development') {
this.level = LogLevel.DEBUG;
}
}
private formatMessage(level: string, message: string, data?: any): string {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${this.prefix}] [${level}] ${message}`;
}
private logWithData(level: string, message: string, data?: any) {
const formattedMessage = this.formatMessage(level, message);
if (data) {
console.log(formattedMessage, data);
} else {
console.log(formattedMessage);
}
}
debug(message: string, data?: any) {
if (this.level <= LogLevel.DEBUG) {
this.logWithData('DEBUG', message, data);
}
}
info(message: string, data?: any) {
if (this.level <= LogLevel.INFO) {
this.logWithData('INFO', message, data);
}
}
warn(message: string, data?: any) {
if (this.level <= LogLevel.WARN) {
console.warn(this.formatMessage('WARN', message), data || '');
}
}
error(message: string, error?: any) {
if (this.level <= LogLevel.ERROR) {
console.error(this.formatMessage('ERROR', message), error || '');
}
}
// Spezielle Methoden für API-Logging
apiRequest(service: string, endpoint: string, params: any) {
this.info(`API Request: ${service} - ${endpoint}`, {
service,
endpoint,
params: this.sanitizeParams(params),
});
}
apiResponse(service: string, endpoint: string, response: any, duration: number) {
this.info(`API Response: ${service} - ${endpoint} (${duration}ms)`, {
service,
endpoint,
duration,
response: this.sanitizeResponse(response),
});
}
apiError(service: string, endpoint: string, error: any, duration?: number) {
this.error(`API Error: ${service} - ${endpoint}${duration ? ` (${duration}ms)` : ''}`, {
service,
endpoint,
duration,
error: error.message || error,
stack: error.stack,
});
}
// Entfernt sensitive Daten aus Params
private sanitizeParams(params: any): any {
if (!params) return params;
const sanitized = { ...params };
// API Keys verstecken
if (sanitized.apiKey) {
sanitized.apiKey = '***HIDDEN***';
}
// Lange Texte kürzen
if (sanitized.messages) {
sanitized.messages = sanitized.messages.map((msg: any) => ({
...msg,
content:
msg.content?.length > 200
? msg.content.substring(0, 200) + '...[TRUNCATED]'
: msg.content,
}));
}
if (sanitized.prompt && sanitized.prompt.length > 200) {
sanitized.prompt = sanitized.prompt.substring(0, 200) + '...[TRUNCATED]';
}
return sanitized;
}
// Kürzt lange Responses
private sanitizeResponse(response: any): any {
if (!response) return response;
if (typeof response === 'string' && response.length > 500) {
return response.substring(0, 500) + '...[TRUNCATED]';
}
if (response.content && typeof response.content === 'string' && response.content.length > 500) {
return {
...response,
content: response.content.substring(0, 500) + '...[TRUNCATED]',
};
}
if (response.choices) {
return {
...response,
choices: response.choices.map((choice: any) => ({
...choice,
message: choice.message
? {
...choice.message,
content:
choice.message.content?.length > 500
? choice.message.content.substring(0, 500) + '...[TRUNCATED]'
: choice.message.content,
}
: choice.message,
})),
};
}
return response;
}
// Timer für Performance-Messung
startTimer(label: string): () => number {
const start = Date.now();
this.debug(`Timer started: ${label}`);
return () => {
const duration = Date.now() - start;
this.debug(`Timer ended: ${label} - ${duration}ms`);
return duration;
};
}
}
// Singleton-Instanzen für verschiedene Module
export const apiLogger = new Logger('API');
export const aiLogger = new Logger('AI');
export const dbLogger = new Logger('DB');
export const appLogger = new Logger('APP');
// Default export
export default Logger;

View file

@ -0,0 +1,190 @@
import { marked } from 'marked';
import {
extractReferences,
fetchReferences,
replaceReferences,
type ReferenceData,
} from '$lib/services/referenceResolver';
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub Flavored Markdown
pedantic: false,
});
/**
* Render markdown to HTML with smart @reference display
* This is the async version that fetches real names
*/
export async function renderMarkdownSmart(
text: string,
context?: { characters?: any[]; place?: any }
): Promise<string> {
if (!text) return '';
console.log('🎨 renderMarkdownSmart input:', text.substring(0, 200));
// Handle REF_X placeholders if they exist (for backward compatibility)
let processedText = text;
if (/REF_\d+/.test(text) && context) {
console.warn('⚠️ Found REF_X placeholders - attempting to fix them...');
// Build mapping from context
const refMapping: Record<string, string> = {};
let refIndex = 0;
// Add characters
if (context.characters) {
context.characters.forEach((char: any) => {
if (char.slug) {
refMapping[`REF_${refIndex}`] = `@${char.slug}`;
console.log(`Mapping REF_${refIndex} → @${char.slug}`);
refIndex++;
}
});
}
// Add place
if (context.place?.slug) {
refMapping[`REF_${refIndex}`] = `@${context.place.slug}`;
console.log(`Mapping REF_${refIndex} → @${context.place.slug}`);
}
// Replace all REF_X with mapped values
for (const [ref, replacement] of Object.entries(refMapping)) {
processedText = processedText.replace(new RegExp(ref, 'g'), replacement);
}
console.log('Fixed text:', processedText.substring(0, 200));
}
// 1. Extract all @references
const slugs = extractReferences(processedText);
console.log('📝 Found slugs in text:', slugs);
// 2. Fetch reference data (with caching)
const references = slugs.length > 0 ? await fetchReferences(slugs) : new Map();
console.log('📚 Fetched references:', Array.from(references.entries()));
// 3. Temporarily protect references from markdown processing
const placeholders: string[] = [];
let protectedText = processedText.replace(/@([\w-]+)/g, (match) => {
placeholders.push(match);
return `__MDREF_${placeholders.length - 1}_MDREF__`;
});
// 4. Render markdown
let html = String(marked.parse(protectedText));
// 5. Restore references with smart display
placeholders.forEach((ref, index) => {
const slug = ref.substring(1);
const data = references.get(slug);
if (data) {
// Use real name from database
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium" data-kind="${data.kind}">${data.title}</a>`
);
} else {
// Fallback: format slug nicely
const displayName = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium opacity-75">${displayName}</a>`
);
}
});
return html;
}
/**
* Immediate markdown rendering (without async lookup)
* Uses simple slug formatting as fallback
*/
export function renderMarkdown(text: string): string {
if (!text) return '';
// First, temporarily replace @references to protect them from markdown
const references: string[] = [];
let protectedText = text.replace(/@([\w-]+)/g, (match) => {
references.push(match);
return `__MDREF_${references.length - 1}_MDREF__`;
});
// Render markdown
let html = String(marked.parse(protectedText));
// Restore @references as links with formatted names
references.forEach((ref, index) => {
const slug = ref.substring(1);
// Simple formatting: finn-zahnrad → Finn Zahnrad
const displayName = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium">${displayName}</a>`
);
});
return html;
}
/**
* Parse @references in plain text (non-markdown)
* This is the async version that fetches real names
*/
export async function parseReferencesSmart(text: string | undefined): Promise<string> {
if (!text) return '';
// Check if text contains markdown formatting
const hasMarkdown = /[#*_`~\[\]]/.test(text);
if (hasMarkdown) {
// Use full markdown rendering with smart display
return renderMarkdownSmart(text);
} else {
// Simple reference parsing for plain text
const slugs = extractReferences(text);
const references = slugs.length > 0 ? await fetchReferences(slugs) : new Map();
return replaceReferences(text, references, {
linkClass: 'text-theme-primary-600 hover:text-theme-primary-500 font-medium',
});
}
}
/**
* Parse @references and create links (immediate version)
*/
export function parseReferences(text: string | undefined): string {
if (!text) return '';
// Check if text contains markdown formatting
const hasMarkdown = /[#*_`~\[\]]/.test(text);
if (hasMarkdown) {
// Use full markdown rendering
return renderMarkdown(text);
} else {
// Simple reference parsing for plain text with formatted names
return text.replace(/@([\w-]+)/g, (match, slug) => {
const displayName = slug
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium">${displayName}</a>`;
});
}
}

View file

@ -0,0 +1,38 @@
/**
* Extracts @mentions from text
* @param text - The text to search for mentions
* @returns Array of slugs mentioned in the text
*/
export function extractMentions(text: string | undefined): string[] {
if (!text) return [];
const regex = /@([\w-]+)/g;
const matches = [...text.matchAll(regex)];
return [...new Set(matches.map((m) => m[1]))]; // Remove duplicates
}
/**
* Parses text and converts @mentions to clickable links
* @param text - The text containing @mentions
* @param baseUrl - Base URL for links (default: '/')
* @returns HTML string with clickable mentions
*/
export function parseReferences(text: string | undefined, baseUrl: string = '/'): string {
if (!text) return '';
return text.replace(
/@([\w-]+)/g,
`<a href="${baseUrl}$1" class="text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300">@$1</a>`
);
}
/**
* Checks if a text mentions a specific slug
* @param text - The text to search in
* @param slug - The slug to search for
* @returns true if the slug is mentioned
*/
export function hasMention(text: string | undefined, slug: string): boolean {
if (!text) return false;
return extractMentions(text).includes(slug);
}

View file

@ -0,0 +1,10 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
return {
session,
user,
};
};

View file

@ -0,0 +1,297 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/stores';
import { createClient } from '$lib/supabase/client';
import { invalidateAll, goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { theme } from '$lib/themes/themeStore';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import GlobalAiAuthorBar from '$lib/components/GlobalAiAuthorBar.svelte';
let { children, data } = $props();
const supabase = createClient();
let navHidden = $state(false);
// Check if we're on a world or place detail page that needs transparent background
let isTransparentPage = $derived(
(() => {
const path = $page.url.pathname;
// Check for world detail pages: /worlds/[slug]
if (path.match(/^\/worlds\/[^\/]+$/)) {
return true;
}
// Check for place detail pages: /worlds/[world]/places/[slug]
if (path.match(/^\/worlds\/[^\/]+\/places\/[^\/]+$/)) {
return true;
}
return false;
})()
);
$effect(() => {
// Set transparent background on body for world/place detail pages
if (typeof document !== 'undefined') {
if (isTransparentPage) {
document.body.style.backgroundColor = 'transparent';
document.documentElement.style.backgroundColor = 'transparent';
} else {
document.body.style.backgroundColor = '';
document.documentElement.style.backgroundColor = '';
}
}
});
$effect(() => {
// Extract world slug from URL if present
const pathSegments = $page.url.pathname.split('/');
if (pathSegments[1] === 'worlds' && pathSegments[2] && pathSegments[2] !== 'new') {
// We're in a world context, ensure it's set
const worldSlug = pathSegments[2];
if (!$currentWorld || $currentWorld.slug !== worldSlug) {
// Load world data if not in store
loadWorld(worldSlug);
}
}
});
async function loadWorld(slug: string) {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
const world = await response.json();
if (world.kind === 'world') {
currentWorld.setWorld(world);
}
}
}
function exitWorld() {
currentWorld.clearWorld();
goto('/');
}
onMount(() => {
theme.init();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
invalidateAll();
});
// Auto-hide navigation on scroll for all pages
let lastScrollY = window.scrollY;
let ticking = false;
let mouseTimer: number | null = null;
function handleScroll() {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// Different behavior for transparent vs normal pages
if (isTransparentPage) {
// Hide nav when at top of page (to show full image)
if (currentScrollY < 100) {
navHidden = true;
} else {
navHidden = false;
}
} else {
// Hide nav when scrolling down, show when scrolling up
if (currentScrollY > lastScrollY && currentScrollY > 100) {
navHidden = true;
} else {
navHidden = false;
}
}
lastScrollY = currentScrollY;
ticking = false;
});
ticking = true;
}
}
function handleMouseMove(e: MouseEvent) {
// Show nav when mouse is near top of screen
if (e.clientY < 100) {
navHidden = false;
// Clear existing timer
if (mouseTimer) {
clearTimeout(mouseTimer);
}
// Hide again after 3 seconds if conditions are met
mouseTimer = window.setTimeout(() => {
const currentScrollY = window.scrollY;
if (isTransparentPage && currentScrollY < 100) {
navHidden = true;
} else if (!isTransparentPage && currentScrollY > 100) {
navHidden = true;
}
}, 3000);
}
}
window.addEventListener('scroll', handleScroll);
window.addEventListener('mousemove', handleMouseMove);
return () => {
subscription.unsubscribe();
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('mousemove', handleMouseMove);
if (mouseTimer) {
clearTimeout(mouseTimer);
}
};
});
onDestroy(() => {
// Reset background when component is destroyed
if (typeof document !== 'undefined') {
document.body.style.backgroundColor = '';
document.documentElement.style.backgroundColor = '';
}
});
// Navigation changes based on world context
let navigation = $derived(
$currentWorld
? [
{ name: 'Stories', href: `/worlds/${$currentWorld.slug}/stories`, kind: 'story' },
{
name: 'Charaktere',
href: `/worlds/${$currentWorld.slug}/characters`,
kind: 'character',
},
{ name: 'Orte', href: `/worlds/${$currentWorld.slug}/places`, kind: 'place' },
{ name: 'Objekte', href: `/worlds/${$currentWorld.slug}/objects`, kind: 'object' },
{ name: 'Welt', href: `/worlds/${$currentWorld.slug}`, kind: 'world' },
]
: [{ name: 'Welten', href: '/', kind: 'world' }]
);
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div
class="min-h-screen {isTransparentPage ? 'bg-transparent' : 'bg-theme-base'} transition-colors"
>
<nav
class="fixed top-0 left-0 right-0 z-50 border-b border-theme-border-subtle bg-theme-surface shadow-sm transition-all duration-300 {navHidden
? '-translate-y-full'
: 'translate-y-0'}"
>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<a href="/" class="text-xl font-bold text-theme-primary-600">Worldream</a>
{#if $currentWorld}
<span class="ml-2 text-theme-text-tertiary">/</span>
<span class="ml-2 text-lg font-semibold text-theme-text-primary"
>{$currentWorld.title}</span
>
{/if}
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
{#each navigation as item}
<a
href={item.href}
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {// Exact match
$page.url.pathname === item.href ||
// For non-world items, check if path starts with href
(item.kind !== 'world' && $page.url.pathname.startsWith(item.href + '/'))
? 'border-theme-primary-500 text-theme-text-primary'
: 'border-transparent text-theme-text-secondary hover:border-theme-border-subtle hover:text-theme-text-primary'}"
>
{item.name}
</a>
{/each}
</div>
</div>
<div class="flex items-center space-x-4">
{#if $currentWorld}
<button
onclick={exitWorld}
class="border-theme-border-default inline-flex items-center rounded-md border bg-theme-surface px-3 py-1 text-sm font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg class="mr-1 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 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
/>
</svg>
Welt verlassen
</button>
{/if}
<!-- Theme Switcher -->
<ThemeSwitcher />
{#if data.user}
<a
href="/database"
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Datenbankstruktur"
>
<svg class="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 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
</a>
<a
href="/settings"
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Einstellungen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</a>
{:else}
<a
href="/auth/login"
class="text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
Anmelden
</a>
{/if}
</div>
</div>
</div>
</nav>
<!-- Nav spacer for all pages since nav is fixed -->
<div class="h-16"></div>
<main class={isTransparentPage ? '' : 'mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8'}>
{@render children?.()}
</main>
<LoadingOverlay />
<GlobalAiAuthorBar />
</div>

View file

@ -0,0 +1,262 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { currentWorld } from '$lib/stores/worldContext';
import { goto } from '$app/navigation';
let { data } = $props();
let worlds = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadWorlds() {
if (!data.user) {
loading = false;
return;
}
try {
const response = await fetch('/api/nodes?kind=world');
if (!response.ok) throw new Error('Failed to load worlds');
worlds = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function enterWorld(world: ContentNode) {
currentWorld.setWorld(world);
goto(`/worlds/${world.slug}`);
}
$effect(() => {
loadWorlds();
});
</script>
<div class="flex min-h-[80vh] flex-col">
<!-- Hero Section -->
<div
class="bg-gradient-to-br from-theme-primary-700 via-theme-primary-600 to-theme-primary-800 text-theme-inverse"
>
<div class="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="mb-4 text-5xl font-bold">Willkommen bei Worldream</h1>
<p class="mx-auto max-w-2xl text-xl text-theme-primary-100">
Erschaffe und erkunde fantastische Welten. Wähle eine Welt aus oder erstelle eine neue, um
deine Geschichten zum Leben zu erwecken.
</p>
</div>
</div>
</div>
{#if !data.user}
<!-- Not logged in -->
<div class="flex flex-1 items-center justify-center bg-theme-base">
<div class="text-center">
<svg
class="mx-auto h-24 w-24 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h2 class="mt-6 text-2xl font-semibold text-theme-text-primary">
Bereit, deine eigenen Welten zu erschaffen?
</h2>
<p class="mt-2 text-theme-text-secondary">
Melde dich an, um deine kreativen Ideen zum Leben zu erwecken.
</p>
<div class="mt-6 space-x-4">
<a
href="/auth/login"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-6 py-3 text-base font-medium text-theme-inverse hover:bg-theme-primary-700"
>
Anmelden
</a>
<a
href="/auth/login"
class="border-theme-border-default inline-flex items-center rounded-md border bg-theme-surface px-6 py-3 text-base font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
Registrieren
</a>
</div>
</div>
</div>
{:else if loading}
<!-- Loading -->
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<div
class="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-theme-primary-600"
></div>
<p class="mt-4 text-theme-text-secondary">Lade deine Welten...</p>
</div>
</div>
{:else if error}
<!-- Error -->
<div class="flex flex-1 items-center justify-center">
<div class="rounded-md bg-red-50/50 p-6">
<p class="text-sm text-theme-error">{error}</p>
</div>
</div>
{:else}
<!-- Worlds Grid -->
<div class="flex-1 bg-theme-base py-12">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between">
<h2 class="text-2xl font-bold text-theme-text-primary">
{worlds.length > 0 ? 'Wähle eine Welt' : 'Erstelle deine erste Welt'}
</h2>
<a
href="/worlds/new"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-theme-inverse shadow-sm hover:bg-theme-primary-700"
>
<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 4v16m8-8H4"
/>
</svg>
Neue Welt
</a>
</div>
{#if worlds.length === 0}
<div class="py-12 text-center">
<svg
class="mx-auto h-24 w-24 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="mt-6 text-lg font-medium text-theme-text-primary">
Noch keine Welten vorhanden
</h3>
<p class="mt-2 text-sm text-theme-text-secondary">
Beginne dein Abenteuer, indem du deine erste Welt erschaffst.
</p>
<div class="mt-6">
<a
href="/worlds/new"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-6 py-3 text-base font-medium text-theme-inverse hover:bg-theme-primary-700"
>
<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 4v16m8-8H4"
/>
</svg>
Erste Welt erschaffen
</a>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each worlds as world}
<button
onclick={() => enterWorld(world)}
class="group relative transform overflow-hidden rounded-lg bg-theme-surface text-left shadow-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
>
<!-- World Card Background with Image -->
<div
class="relative h-48 overflow-hidden bg-gradient-to-br from-theme-primary-500 to-theme-primary-600"
>
{#if world.image_url}
<img
src={world.image_url}
alt={world.title}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
></div>
{:else}
<!-- Fallback pattern for worlds without images -->
<div class="absolute inset-0 opacity-10">
<svg class="h-full w-full" viewBox="0 0 100 100" fill="currentColor">
<pattern
id="world-pattern-{world.id}"
patternUnits="userSpaceOnUse"
width="20"
height="20"
>
<circle cx="10" cy="10" r="1.5" />
</pattern>
<rect width="100" height="100" fill="url(#world-pattern-{world.id})" />
</svg>
</div>
{/if}
</div>
<!-- World Content -->
<div class="p-6">
<h3
class="text-xl font-bold text-theme-text-primary transition-colors group-hover:text-theme-primary-600"
>
{world.title}
</h3>
{#if world.summary}
<p class="mt-2 line-clamp-2 text-sm text-theme-text-secondary">
{world.summary}
</p>
{/if}
<!-- World Stats -->
<div class="mt-4 flex items-center justify-between">
<div class="flex space-x-2">
{#if world.tags && world.tags.length > 0}
{#each world.tags.slice(0, 2) as tag}
<span
class="inline-flex items-center rounded-full bg-theme-primary-100 px-2.5 py-0.5 text-xs font-medium text-theme-primary-700"
>
{tag}
</span>
{/each}
{/if}
</div>
<span class="inline-flex items-center text-sm text-theme-text-secondary">
<svg
class="mr-1 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 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
Betreten
</span>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,74 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { editContentWithAI } from '$lib/ai/editing';
import type { ContentNode } from '$lib/types/content';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { nodeSlug, command } = await request.json();
if (!nodeSlug || !command) {
return json({ error: 'Missing required fields: nodeSlug, command' }, { status: 400 });
}
if (typeof command !== 'string' || command.trim().length === 0) {
return json({ error: 'Command must be a non-empty string' }, { status: 400 });
}
const supabase = locals.supabase;
// Get current node data
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', nodeSlug)
.single();
if (fetchError || !node) {
return json({ error: 'Node not found' }, { status: 404 });
}
// Check ownership
if (node.owner_id !== session.user.id) {
return json({ error: 'Forbidden: You do not own this content' }, { status: 403 });
}
// Use AI to generate updates
const updates = await editContentWithAI({
node: node as ContentNode,
command: command.trim(),
});
// Apply updates to database
const { data: updatedNode, error: updateError } = await supabase
.from('content_nodes')
.update(updates)
.eq('slug', nodeSlug)
.select()
.single();
if (updateError) {
console.error('Database update failed:', updateError);
return json({ error: 'Failed to update content' }, { status: 500 });
}
return json({
success: true,
updatedNode,
appliedUpdates: updates,
});
} catch (error) {
console.error('AI editing failed:', error);
if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
}
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View file

@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { enhanceContent } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { content, kind, instruction } = await request.json();
if (!content || !kind || !instruction) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const enhanced = await enhanceContent(content, kind, instruction);
return json({ content: enhanced });
} catch (error) {
console.error('AI enhancement error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to enhance content',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,139 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateImageWithFlux } from '$lib/ai/replicate-flux';
import { translateToImagePrompt } from '$lib/ai/openai';
import { createClient } from '$lib/supabase/server';
import { uploadImage } from '$lib/storage/images';
import type { NodeKind } from '$lib/types/content';
export const POST: RequestHandler = async (event) => {
const { request } = event;
try {
const supabase = createClient(event);
// Prüfe Authentifizierung
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return json({ error: 'Nicht authentifiziert' }, { status: 401 });
}
const body = await request.json();
const {
kind,
title,
description,
style = 'fantasy',
context,
nodeId,
aspectRatio,
imagePrompt,
} = body as {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: any;
nodeId?: string;
aspectRatio?: string;
imagePrompt?: string;
};
if (!kind || !title) {
return json({ error: 'Kind und Title sind erforderlich' }, { status: 400 });
}
// Bestimme die beste Beschreibung für die Bildgenerierung
let finalDescription = description;
// 1. Nutze vorhandenen imagePrompt falls vorhanden
if (imagePrompt) {
finalDescription = imagePrompt;
}
// 2. Falls deutsche Beschreibung vorhanden, übersetze sie
else if (context?.appearance && context.appearance.length > 10) {
try {
console.log('Übersetze deutsche Beschreibung ins Englische...');
finalDescription = await translateToImagePrompt(context.appearance, kind, title, style);
console.log('Übersetzung erfolgreich:', finalDescription.substring(0, 100) + '...');
} catch (error) {
console.warn('Übersetzung fehlgeschlagen, verwende deutsche Beschreibung:', error);
finalDescription = context.appearance;
}
}
// Generiere Bild mit Flux Schnell über Replicate
const result = await generateImageWithFlux({
kind,
title,
description: finalDescription,
style,
context: {
...context,
// Überschreibe appearance mit übersetzter Version
appearance: finalDescription,
},
aspectRatio,
});
// Wenn ein Bild generiert wurde, speichere es in Supabase
let uploadedImageUrl = null;
if (result.imageUrl) {
try {
let imageBlob: Blob;
// Prüfe ob es Base64 oder eine URL ist
if (result.imageUrl.startsWith('data:')) {
// Base64 zu Blob konvertieren
const base64Data = result.imageUrl.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
imageBlob = new Blob([byteArray], { type: 'image/png' });
} else {
// Lade das Bild von der URL herunter
const imageResponse = await fetch(result.imageUrl);
imageBlob = await imageResponse.blob();
}
// Generiere eine temporäre nodeId falls keine vorhanden
const tempNodeId = nodeId || `temp-${Date.now()}`;
const uploadResult = await uploadImage(
supabase,
user.id,
tempNodeId,
imageBlob,
`${title.toLowerCase().replace(/\s+/g, '-')}.png`
);
if (uploadResult) {
uploadedImageUrl = uploadResult.url;
}
} catch (uploadError) {
console.error('Fehler beim Hochladen des Bildes:', uploadError);
// Gebe trotzdem die Original-URL zurück
uploadedImageUrl = result.imageUrl;
}
}
return json({
success: true,
imageUrl: uploadedImageUrl || result.imageUrl || null,
prompt: result.prompt,
message: uploadedImageUrl
? 'Bild erfolgreich generiert und gespeichert'
: result.imageUrl
? 'Bild generiert (temporäre URL)'
: 'Bildgenerierung fehlgeschlagen',
});
} catch (error) {
console.error('Fehler bei Bildgenerierung:', error);
return json({ error: 'Fehler bei der Bildgenerierung' }, { status: 500 });
}
};

View file

@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateContent } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { kind, prompt, context, node_id } = await request.json();
if (!kind || !prompt) {
return json({ error: 'Missing required fields: kind and prompt' }, { status: 400 });
}
const result = await generateContent({
kind,
prompt,
context,
});
// Optionally save to prompt history if node_id is provided
if (node_id && locals.supabase) {
await locals.supabase.from('prompt_history').insert({
user_id: session.user.id,
node_id,
prompt,
response: result,
model: 'gpt-5-mini',
});
}
return json(result);
} catch (error) {
console.error('AI generation error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to generate content',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateSuggestions } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { field, context } = await request.json();
if (!field || !context?.kind) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const suggestions = await generateSuggestions(field, context);
return json({ suggestions });
} catch (error) {
console.error('AI suggestion error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to generate suggestions',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,58 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { translateToImagePrompt } from '$lib/ai/openai';
import { createClient } from '$lib/supabase/server';
import type { NodeKind } from '$lib/types/content';
export const POST: RequestHandler = async (event) => {
const { request } = event;
try {
const supabase = createClient(event);
// Prüfe Authentifizierung
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return json({ error: 'Nicht authentifiziert' }, { status: 401 });
}
const body = await request.json();
const {
germanDescription,
kind,
title,
style = 'fantasy',
} = body as {
germanDescription: string;
kind: NodeKind;
title: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
};
if (!germanDescription || !kind || !title) {
return json(
{ error: 'German description, kind und title sind erforderlich' },
{ status: 400 }
);
}
// Übersetze deutsche Beschreibung ins Englische
console.log('Übersetze deutsche Beschreibung:', germanDescription.substring(0, 100) + '...');
const englishPrompt = await translateToImagePrompt(germanDescription, kind, title, style);
console.log('Übersetzung erfolgreich:', englishPrompt.substring(0, 100) + '...');
return json({
success: true,
englishPrompt,
message: 'Übersetzung erfolgreich',
});
} catch (error) {
console.error('Fehler bei der Prompt-Übersetzung:', error);
return json({ error: 'Übersetzung fehlgeschlagen' }, { status: 500 });
}
};

View file

@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { ContentNode, NodeKind } from '$lib/types/content';
export const GET: RequestHandler = async ({ url, locals }) => {
const supabase = locals.supabase;
const kind = url.searchParams.get('kind') as NodeKind | null;
const world_slug = url.searchParams.get('world_slug');
const search = url.searchParams.get('search');
const limit = parseInt(url.searchParams.get('limit') || '50');
const offset = parseInt(url.searchParams.get('offset') || '0');
let query = supabase
.from('content_nodes')
.select('*')
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (kind) {
query = query.eq('kind', kind);
}
if (world_slug) {
query = query.eq('world_slug', world_slug);
}
if (search) {
query = query.textSearch('search_tsv', search);
}
const { data, error } = await query;
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const supabase = locals.supabase;
const node: Partial<ContentNode> = {
...body,
owner_id: session.user.id,
};
const { data, error } = await supabase.from('content_nodes').insert(node).select().single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data, { status: 201 });
};

View file

@ -0,0 +1,146 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, locals }) => {
const supabase = locals.supabase;
const { slug } = params;
const { data, error } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', slug)
.single();
if (error) {
if (error.code === 'PGRST116') {
return json({ error: 'Node not found' }, { status: 404 });
}
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
const updates = await request.json();
// First, check if user owns this node
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Create revision before updating
const { data: currentNode } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', slug)
.single();
if (currentNode) {
await supabase.from('node_revisions').insert({
node_id: currentNode.id,
node_slug: slug,
content_before: currentNode.content,
content_after: updates.content || currentNode.content,
edited_by: session.user.id,
});
}
// Update the node
const { data, error } = await supabase
.from('content_nodes')
.update(updates)
.eq('slug', slug)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
const updates = await request.json();
// Check ownership
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id, slug')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Handle slug changes
const newSlug = updates.slug || slug;
const updateData = {
...updates,
updated_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from('content_nodes')
.update(updateData)
.eq('slug', slug)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
// Check ownership
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
const { error } = await supabase.from('content_nodes').delete().eq('slug', slug);
if (error) {
return json({ error: error.message }, { status: 500 });
}
return new Response(null, { status: 204 });
};

View file

@ -0,0 +1,356 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { CustomFieldData, CustomFieldSchema } from '$lib/types/customFields';
// GET /api/nodes/[slug]/custom-data - Get custom field data for a node
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
// Get the node with its custom data
const { data: node, error: fetchError } = await locals.supabase
.from('content_nodes')
.select('id, slug, custom_data, custom_schema, owner_id, visibility')
.eq('slug', slug)
.single();
if (fetchError) {
throw error(404, 'Node not found');
}
// Check permissions
const canView =
node.owner_id === session.user.id ||
node.visibility === 'public' ||
(node.visibility === 'shared' && session.user);
if (!canView) {
throw error(403, 'Access denied');
}
// Calculate formula fields if schema exists
let processedData = node.custom_data || {};
if (node.custom_schema) {
processedData = await calculateFormulas(node.custom_schema, processedData);
}
return json({
data: processedData,
schema: node.custom_schema,
});
} catch (err) {
console.error('Error fetching custom data:', err);
throw error(500, 'Failed to fetch custom data');
}
};
// PUT /api/nodes/[slug]/custom-data - Update all custom data
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const customData = body.data as CustomFieldData;
// Get the node to check ownership and schema
const { data: node, error: fetchError } = await locals.supabase
.from('content_nodes')
.select('id, owner_id, custom_schema')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can modify custom data');
}
// Validate data against schema
if (node.custom_schema) {
const validation = validateData(node.custom_schema, customData);
if (!validation.valid) {
throw error(400, JSON.stringify(validation.errors));
}
}
// Update the custom data
const { error: updateError } = await locals.supabase
.from('content_nodes')
.update({
custom_data: customData,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to update custom data');
}
// Calculate formulas and return processed data
const processedData = node.custom_schema
? await calculateFormulas(node.custom_schema, customData)
: customData;
return json({
success: true,
data: processedData,
});
} catch (err) {
console.error('Error updating custom data:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update custom data');
}
};
// PATCH /api/nodes/[slug]/custom-data - Partial update of custom data
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const updates = body.data as Partial<CustomFieldData>;
// Get the current node data
const { data: node, error: fetchError } = await locals.supabase
.from('content_nodes')
.select('id, owner_id, custom_schema, custom_data')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can modify custom data');
}
// Merge with existing data
const mergedData = {
...(node.custom_data || {}),
...updates,
};
// Validate merged data against schema
if (node.custom_schema) {
const validation = validateData(node.custom_schema, mergedData);
if (!validation.valid) {
throw error(400, JSON.stringify(validation.errors));
}
}
// Update the custom data
const { error: updateError } = await locals.supabase
.from('content_nodes')
.update({
custom_data: mergedData,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to update custom data');
}
// Calculate formulas and return processed data
const processedData = node.custom_schema
? await calculateFormulas(node.custom_schema, mergedData)
: mergedData;
return json({
success: true,
data: processedData,
});
} catch (err) {
console.error('Error patching custom data:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to patch custom data');
}
};
// Helper function to validate data against schema
function validateData(
schema: CustomFieldSchema,
data: CustomFieldData
): { valid: boolean; errors: any[] } {
const errors: any[] = [];
for (const field of schema.fields) {
const value = data[field.key];
// Check required fields
if (field.required && (value === undefined || value === null || value === '')) {
errors.push({
field: field.key,
message: `${field.label} is required`,
});
continue;
}
// Skip validation if field is empty and not required
if (!field.required && (value === undefined || value === null)) {
continue;
}
// Type-specific validation
switch (field.type) {
case 'number':
case 'range':
if (typeof value !== 'number') {
errors.push({
field: field.key,
message: `${field.label} must be a number`,
});
} else {
if (field.config.min !== undefined && value < field.config.min) {
errors.push({
field: field.key,
message: `${field.label} must be at least ${field.config.min}`,
});
}
if (field.config.max !== undefined && value > field.config.max) {
errors.push({
field: field.key,
message: `${field.label} must be at most ${field.config.max}`,
});
}
}
break;
case 'text':
if (typeof value !== 'string') {
errors.push({
field: field.key,
message: `${field.label} must be text`,
});
} else {
if (field.config.maxLength && value.length > field.config.maxLength) {
errors.push({
field: field.key,
message: `${field.label} must be at most ${field.config.maxLength} characters`,
});
}
if (field.config.pattern) {
const regex = new RegExp(field.config.pattern);
if (!regex.test(value)) {
errors.push({
field: field.key,
message: `${field.label} has invalid format`,
});
}
}
}
break;
case 'select':
if (field.config.choices) {
const validValues = field.config.choices.map((c) => c.value);
if (!validValues.includes(value)) {
errors.push({
field: field.key,
message: `${field.label} has invalid value`,
});
}
}
break;
case 'multiselect':
if (!Array.isArray(value)) {
errors.push({
field: field.key,
message: `${field.label} must be an array`,
});
} else if (field.config.choices) {
const validValues = field.config.choices.map((c) => c.value);
for (const v of value) {
if (!validValues.includes(v)) {
errors.push({
field: field.key,
message: `${field.label} contains invalid value: ${v}`,
});
}
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push({
field: field.key,
message: `${field.label} must be true or false`,
});
}
break;
case 'list':
if (!Array.isArray(value)) {
errors.push({
field: field.key,
message: `${field.label} must be a list`,
});
} else {
if (field.config.min_items && value.length < field.config.min_items) {
errors.push({
field: field.key,
message: `${field.label} must have at least ${field.config.min_items} items`,
});
}
if (field.config.max_items && value.length > field.config.max_items) {
errors.push({
field: field.key,
message: `${field.label} must have at most ${field.config.max_items} items`,
});
}
}
break;
}
}
return {
valid: errors.length === 0,
errors,
};
}
// Helper function to calculate formula fields
async function calculateFormulas(
schema: CustomFieldSchema,
data: CustomFieldData
): Promise<CustomFieldData> {
const result = { ...data };
// For now, just copy formula strings as-is
// In a real implementation, we'd evaluate them here
for (const field of schema.fields) {
if (field.type === 'formula' && field.config.formula) {
// TODO: Implement actual formula evaluation
// For now, just store the formula
result[field.key] = `[Formula: ${field.config.formula}]`;
}
}
return result;
}

View file

@ -0,0 +1,115 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
// Temporary endpoint that works without node_images table
// Until migration can be run with Docker/Supabase
export const GET: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Get the node - if it has an image_url, return it as primary image
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('image_url, generation_prompt')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
return json({ error: 'Node not found' }, { status: 404 });
}
// Convert existing image to new format
const images = [];
if (node.image_url) {
images.push({
id: 'temp-primary',
image_url: node.image_url,
prompt: node.generation_prompt,
is_primary: true,
sort_order: 0,
created_at: new Date().toISOString(),
});
}
return json(images);
};
export const POST: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the node and verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id, image_url, slug, title')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
console.error('Node lookup error for slug:', params.slug, 'Error:', nodeError);
console.error('Full params:', params);
// Try to find similar nodes for debugging
const { data: similarNodes } = await supabase
.from('content_nodes')
.select('slug, title')
.ilike('slug', `%${params.slug}%`)
.limit(5);
console.error('Similar nodes found:', similarNodes);
return json(
{
error: 'Node not found',
details: nodeError?.message,
searchedSlug: params.slug,
similarNodes: similarNodes,
},
{ status: 404 }
);
}
if (node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// For now, just update the main image_url field
// This is temporary until the migration can be run
const { data: updatedNode, error: updateError } = await supabase
.from('content_nodes')
.update({
image_url: body.image_url,
generation_prompt: body.prompt,
})
.eq('id', node.id)
.select()
.single();
if (updateError) {
return json({ error: updateError.message }, { status: 500 });
}
// Return in the expected format
const imageRecord = {
id: 'temp-new',
image_url: body.image_url,
prompt: body.prompt,
is_primary: true,
sort_order: 0,
created_at: new Date().toISOString(),
node_id: node.id,
};
return json(imageRecord, { status: 201 });
};

View file

@ -0,0 +1,103 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
export const GET: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Get all image attachments for this node
const { data: attachments, error } = await supabase
.from('attachments')
.select('*')
.eq('node_slug', params.slug)
.eq('kind', 'image')
.order('is_primary', { ascending: false })
.order('sort_order')
.order('created_at', { ascending: false });
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform attachments to expected image format
const images = (attachments || []).map((attachment) => ({
id: attachment.id,
image_url: attachment.url,
prompt: attachment.generation_prompt,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
}));
return json(images);
};
export const POST: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify node exists and user owns it
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
console.error('Node lookup error:', nodeError, 'Params:', params);
return json({ error: 'Node not found', details: nodeError?.message }, { status: 404 });
}
if (node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Check if this should be the primary image (first image or explicitly set)
const { count } = await supabase
.from('attachments')
.select('*', { count: 'exact', head: true })
.eq('node_slug', params.slug)
.eq('kind', 'image');
const isPrimary = body.is_primary !== undefined ? body.is_primary : count === 0;
// Insert the new image attachment
const { data: attachment, error } = await supabase
.from('attachments')
.insert({
node_slug: params.slug,
kind: 'image',
url: body.image_url,
generation_prompt: body.prompt,
is_primary: isPrimary,
sort_order: body.sort_order || count || 0,
})
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform to expected image format
const image = {
id: attachment.id,
image_url: attachment.url,
prompt: attachment.generation_prompt,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
};
return json(image, { status: 201 });
};

View file

@ -0,0 +1,126 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
export const PATCH: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the attachment and verify ownership through the node
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.select(
`
*,
node:content_nodes!inner(owner_id, slug)
`
)
.eq('id', params.id)
.eq('kind', 'image')
.single();
if (attachmentError || !attachment) {
return json({ error: 'Image not found' }, { status: 404 });
}
if (attachment.node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Update the attachment
const updates: any = {};
if (body.is_primary !== undefined) updates.is_primary = body.is_primary;
if (body.sort_order !== undefined) updates.sort_order = body.sort_order;
const { data: updatedAttachment, error } = await supabase
.from('attachments')
.update(updates)
.eq('id', params.id)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform to expected image format
const updatedImage = {
id: updatedAttachment.id,
image_url: updatedAttachment.url,
prompt: updatedAttachment.generation_prompt,
is_primary: updatedAttachment.is_primary,
sort_order: updatedAttachment.sort_order,
created_at: updatedAttachment.created_at,
};
return json(updatedImage);
};
export const DELETE: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the attachment and verify ownership through the node
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.select(
`
*,
node:content_nodes!inner(owner_id)
`
)
.eq('id', params.id)
.eq('kind', 'image')
.single();
if (attachmentError || !attachment) {
return json({ error: 'Image not found' }, { status: 404 });
}
if (attachment.node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Delete the attachment
const { error } = await supabase.from('attachments').delete().eq('id', params.id);
if (error) {
return json({ error: error.message }, { status: 500 });
}
// If this was the primary image, make the next image primary
if (attachment.is_primary) {
const { data: nextAttachment } = await supabase
.from('attachments')
.select('id')
.eq('node_slug', attachment.node_slug)
.eq('kind', 'image')
.order('sort_order')
.order('created_at')
.limit(1)
.single();
if (nextAttachment) {
await supabase.from('attachments').update({ is_primary: true }).eq('id', nextAttachment.id);
}
}
return json({ success: true });
};

View file

@ -0,0 +1,147 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
import { createId } from '@paralleldrive/cuid2';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
export const POST: RequestHandler = async (event) => {
const { request, params } = event;
const supabase = createClient(event);
const nodeSlug = params.slug;
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw error(401, 'Unauthorized');
}
try {
// Get the node to verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', nodeSlug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== user.id) {
throw error(403, 'Not authorized to upload images to this node');
}
// Parse multipart form data
const formData = await request.formData();
const imageFile = formData.get('image') as File;
const isPrimary = formData.get('is_primary') === 'true';
if (!imageFile) {
throw error(400, 'No image file provided');
}
// Validate file type
if (!ALLOWED_TYPES.includes(imageFile.type)) {
throw error(400, 'Invalid file type. Only JPEG, PNG, WebP and GIF are allowed');
}
// Validate file size
if (imageFile.size > MAX_FILE_SIZE) {
throw error(400, 'File too large. Maximum size is 10MB');
}
// Generate unique filename
const fileExt = imageFile.name.split('.').pop()?.toLowerCase() || 'jpg';
const fileName = `${nodeSlug}/${createId()}.${fileExt}`;
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('node-images')
.upload(fileName, imageFile, {
contentType: imageFile.type,
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
console.error('Storage upload error:', uploadError);
throw error(500, 'Failed to upload image');
}
// Get public URL
const {
data: { publicUrl },
} = supabase.storage.from('node-images').getPublicUrl(fileName);
// If this should be primary, unset other primary images first
if (isPrimary) {
await supabase
.from('attachments')
.update({ is_primary: false })
.eq('node_slug', nodeSlug)
.eq('kind', 'image');
}
// Check if there are any existing images
const { count } = await supabase
.from('attachments')
.select('*', { count: 'exact', head: true })
.eq('node_slug', nodeSlug)
.eq('kind', 'image');
// Create attachment record
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.insert({
node_slug: nodeSlug,
kind: 'image',
file_url: publicUrl,
storage_path: fileName,
metadata: {
original_name: imageFile.name,
size: imageFile.size,
type: imageFile.type,
},
is_primary: isPrimary || count === 0, // Set as primary if requested or if it's the first image
sort_order: (count || 0) + 1,
})
.select()
.single();
if (attachmentError) {
// Try to clean up the uploaded file
await supabase.storage.from('node-images').remove([fileName]);
console.error('Attachment creation error:', attachmentError);
throw error(500, 'Failed to create attachment record');
}
// Dispatch event to update UI
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('images-updated', {
detail: { nodeSlug },
})
);
}
return json({
id: attachment.id,
image_url: publicUrl,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
});
} catch (err) {
console.error('Upload error:', err);
if (err instanceof Response) {
throw err;
}
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,154 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MemoryService } from '$lib/services/memoryService';
import { createClient } from '$lib/supabase/server';
// GET /api/nodes/[slug]/memory - Get node memory
export const GET: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Get the node to verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
// Check if user has access
if (node.owner_id !== user.id) {
throw error(403, "You do not have access to this node's memory");
}
const memory = await MemoryService.getMemory(node.id);
return json(memory);
} catch (err) {
console.error('Error fetching memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to fetch memory');
}
};
// POST /api/nodes/[slug]/memory - Add a new memory
export const POST: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const body = await request.json();
const {
content,
tier = 'short',
importance = 5,
tags = [],
involved = [],
location,
emotional_weight,
} = body;
if (!content) {
throw error(400, 'Memory content is required');
}
const success = await MemoryService.addMemory(node.id, content, tier, {
importance,
tags,
involved,
location,
emotional_weight,
});
if (!success) {
throw error(500, 'Failed to add memory');
}
return json({ success: true });
} catch (err) {
console.error('Error adding memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to add memory');
}
};
// PUT /api/nodes/[slug]/memory - Update entire memory object
export const PUT: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const memory = await request.json();
const success = await MemoryService.updateMemory(node.id, memory);
if (!success) {
throw error(500, 'Failed to update memory');
}
return json({ success: true });
} catch (err) {
console.error('Error updating memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update memory');
}
};

View file

@ -0,0 +1,48 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MemoryService } from '$lib/services/memoryService';
import { createClient } from '$lib/supabase/server';
// DELETE /api/nodes/[slug]/memory/[memoryId] - Delete a specific memory
export const DELETE: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug, memoryId } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const success = await MemoryService.deleteMemory(node.id, memoryId);
if (!success) {
throw error(500, 'Failed to delete memory');
}
return json({ success: true });
} catch (err) {
console.error('Error deleting memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete memory');
}
};

View file

@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MemoryService } from '$lib/services/memoryService';
import { createClient } from '$lib/supabase/server';
// POST /api/nodes/[slug]/memory/process - Process and age memories
export const POST: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, "You do not have permission to process this node's memory");
}
const body = await request.json();
const { current_date } = body;
const processedMemory = await MemoryService.processMemories(
node.id,
current_date ? new Date(current_date) : undefined
);
if (!processedMemory) {
throw error(500, 'Failed to process memories');
}
return json(processedMemory);
} catch (err) {
console.error('Error processing memories:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to process memories');
}
};

View file

@ -0,0 +1,194 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
import type { CustomFieldSchema, ValidationResult } from '$lib/types/customFields';
// GET /api/nodes/[slug]/schema - Get custom field schema for a node
export const GET: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
const supabase = createClient(event);
try {
// Get the node with its custom schema
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, slug, custom_schema, schema_version, owner_id, visibility')
.eq('slug', slug)
.single();
if (fetchError) {
throw error(404, 'Node not found');
}
// Check permissions
const canView =
node.owner_id === session.user.id ||
node.visibility === 'public' ||
(node.visibility === 'shared' && session.user); // TODO: Check actual share permissions
if (!canView) {
throw error(403, 'Access denied');
}
return json({
schema: node.custom_schema || null,
version: node.schema_version || 1,
});
} catch (err) {
console.error('Error fetching schema:', err);
throw error(500, 'Failed to fetch schema');
}
};
// PUT /api/nodes/[slug]/schema - Update the entire schema
export const PUT: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
const supabase = createClient(event);
try {
const body = await request.json();
const schema = body.schema as CustomFieldSchema;
// Validate schema structure
if (!schema || !Array.isArray(schema.fields)) {
throw error(400, 'Invalid schema structure');
}
// Get the node to check ownership
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, owner_id, schema_version')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can modify the schema');
}
// Validate the schema using the database function
const { data: isValid, error: validationError } = await supabase.rpc('validate_custom_schema', {
p_schema: schema,
});
if (validationError || !isValid) {
throw error(
400,
'Invalid schema: ' + (validationError?.message || 'Schema validation failed')
);
}
// Update the schema
const newVersion = (node.schema_version || 0) + 1;
const { error: updateError } = await supabase
.from('content_nodes')
.update({
custom_schema: schema,
schema_version: newVersion,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to update schema');
}
// If there's existing custom_data, validate it against the new schema
// This could trigger data migration or warnings
const { data: nodeData, error: dataError } = await supabase
.from('content_nodes')
.select('custom_data')
.eq('slug', slug)
.single();
let validationResult: ValidationResult = { valid: true, errors: [] };
if (nodeData?.custom_data) {
// TODO: Implement data validation against new schema
// For now, we'll just pass through
}
return json({
success: true,
version: newVersion,
validation: validationResult,
});
} catch (err) {
console.error('Error updating schema:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update schema');
}
};
// DELETE /api/nodes/[slug]/schema - Clear the schema
export const DELETE: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
const supabase = createClient(event);
try {
// Get the node to check ownership
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can delete the schema');
}
// Clear the schema and data
const { error: updateError } = await supabase
.from('content_nodes')
.update({
custom_schema: null,
custom_data: null,
schema_version: 0,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to clear schema');
}
return json({ success: true });
} catch (err) {
console.error('Error clearing schema:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to clear schema');
}
};

View file

@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { PromptTemplate, NodeKind } from '$lib/types/content';
export const GET: RequestHandler = async ({ url, locals }) => {
const supabase = locals.supabase;
const { session } = await locals.safeGetSession();
const kind = url.searchParams.get('kind') as NodeKind | null;
const world_slug = url.searchParams.get('world_slug');
let query = supabase
.from('prompt_templates')
.select('*')
.order('usage_count', { ascending: false });
if (kind) {
query = query.eq('kind', kind);
}
if (world_slug) {
query = query.eq('world_slug', world_slug);
}
// Get user's own templates and public templates
if (session) {
query = query.or(`owner_id.eq.${session.user.id},is_public.eq.true`);
} else {
query = query.eq('is_public', true);
}
const { data, error } = await query;
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const supabase = locals.supabase;
const template: Partial<PromptTemplate> = {
...body,
owner_id: session.user.id,
};
const { data, error } = await supabase
.from('prompt_templates')
.insert(template)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data, { status: 201 });
};

View file

@ -0,0 +1,137 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
import type { TemplateFilter } from '$lib/types/customFields';
// GET /api/templates - Get custom field templates
export const GET: RequestHandler = async (event) => {
const { url, locals } = event;
const session = await locals.getSession();
const supabase = createClient(event);
try {
// Parse query parameters
const category = url.searchParams.get('category');
const applicableTo = url.searchParams.get('applicable_to');
const tags = url.searchParams.get('tags')?.split(',').filter(Boolean);
const worldSlug = url.searchParams.get('world_slug');
const isPublic = url.searchParams.get('is_public') === 'true';
const search = url.searchParams.get('search');
const sortBy = url.searchParams.get('sort_by') || 'usage_count';
const sortOrder = url.searchParams.get('sort_order') || 'desc';
const limit = parseInt(url.searchParams.get('limit') || '50');
const offset = parseInt(url.searchParams.get('offset') || '0');
// Build query
let query = supabase.from('custom_field_templates').select('*');
// Apply filters
if (isPublic) {
query = query.eq('is_public', true);
} else if (session?.user) {
// Show public templates and user's own templates
query = query.or(`is_public.eq.true,author_id.eq.${session.user.id}`);
} else {
// Only public templates for anonymous users
query = query.eq('is_public', true);
}
if (category) {
query = query.eq('category', category);
}
if (applicableTo) {
query = query.contains('applicable_to', [applicableTo]);
}
if (tags && tags.length > 0) {
query = query.overlaps('tags', tags);
}
if (worldSlug) {
query = query.eq('world_slug', worldSlug);
}
if (search) {
query = query.or(`name.ilike.%${search}%,description.ilike.%${search}%`);
}
// Apply sorting
const validSortFields = ['usage_count', 'created_at', 'updated_at', 'name'];
if (validSortFields.includes(sortBy)) {
query = query.order(sortBy, { ascending: sortOrder === 'asc' });
}
// Apply pagination
query = query.range(offset, offset + limit - 1);
const { data: templates, error: fetchError } = await query;
if (fetchError) {
console.error('Error fetching templates:', fetchError);
throw error(500, 'Failed to fetch templates');
}
return json({
templates: templates || [],
total: templates?.length || 0,
limit,
offset,
});
} catch (err) {
console.error('Error in templates endpoint:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Internal server error');
}
};
// POST /api/templates - Create a new template
export const POST: RequestHandler = async (event) => {
const { request, locals } = event;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
const supabase = createClient(event);
try {
const body = await request.json();
// Validate required fields
if (!body.name || !body.slug || !body.fields || !Array.isArray(body.fields)) {
throw error(400, 'Missing required fields');
}
// Create template
const { data: template, error: createError } = await supabase
.from('custom_field_templates')
.insert({
...body,
author_id: session.user.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.select()
.single();
if (createError) {
if (createError.code === '23505') {
// Unique constraint violation
throw error(409, 'A template with this slug already exists');
}
throw error(500, 'Failed to create template');
}
return json(template);
} catch (err) {
console.error('Error creating template:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/authStore.svelte';
let email = $state('');
let password = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let mode: 'login' | 'signup' = $state('login');
async function handleAuth() {
loading = true;
error = null;
try {
if (mode === 'login') {
const result = await authStore.signIn(email, password);
if (!result.success) {
error = result.error || 'Anmeldung fehlgeschlagen';
return;
}
} else {
const result = await authStore.signUp(email, password);
if (!result.success) {
error = result.error || 'Registrierung fehlgeschlagen';
return;
}
if (result.needsVerification) {
error = 'Bitte bestätige deine E-Mail-Adresse';
return;
}
}
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function toggleMode() {
mode = mode === 'login' ? 'signup' : 'login';
error = null;
}
</script>
<div class="flex min-h-screen items-center justify-center bg-theme-base">
<div class="w-full max-w-md space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-theme-text-primary">
{mode === 'login' ? 'Anmelden' : 'Registrieren'}
</h2>
</div>
<form class="mt-8 space-y-6" onsubmit={handleAuth}>
{#if error}
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<div class="-space-y-px rounded-md shadow-sm">
<div>
<label for="email" class="sr-only">E-Mail</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
class="border-theme-border-default relative block w-full appearance-none rounded-none rounded-t-md border bg-theme-elevated px-3 py-2 text-theme-text-primary placeholder-theme-text-tertiary focus:z-10 focus:border-theme-primary-500 focus:outline-none focus:ring-theme-primary-500 sm:text-sm"
placeholder="E-Mail Adresse"
/>
</div>
<div>
<label for="password" class="sr-only">Passwort</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={password}
class="border-theme-border-default relative block w-full appearance-none rounded-none rounded-b-md border bg-theme-elevated px-3 py-2 text-theme-text-primary placeholder-theme-text-tertiary focus:z-10 focus:border-theme-primary-500 focus:outline-none focus:ring-theme-primary-500 sm:text-sm"
placeholder="Passwort"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
class="group relative flex w-full justify-center rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-theme-primary-700 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50"
>
{loading ? 'Wird verarbeitet...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
</div>
<div class="text-center">
<button
type="button"
onclick={toggleMode}
class="text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
{mode === 'login'
? 'Noch kein Konto? Jetzt registrieren'
: 'Bereits registriert? Jetzt anmelden'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* Logout endpoint - redirects to login page
* Actual logout is handled client-side by authStore.signOut()
*/
export const POST: RequestHandler = async () => {
redirect(303, '/auth/login');
};

View file

@ -0,0 +1,103 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
let { data } = $props();
let nodes = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadCharacters() {
try {
const response = await fetch('/api/nodes?kind=character');
if (!response.ok) throw new Error('Failed to load characters');
nodes = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
$effect(() => {
loadCharacters();
});
</script>
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-text-primary">Charaktere</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Verwalte deine Charaktere und erschaffe neue Persönlichkeiten
</p>
</div>
{#if data.user}
<div class="mt-4 sm:mt-0">
<a
href="/characters/new"
class="inline-flex items-center 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"
>
Neuer Charakter
</a>
</div>
{/if}
</div>
{#if loading}
<div class="py-12 text-center">
<p class="text-theme-text-secondary">Lade Charaktere...</p>
</div>
{:else if error}
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{:else if nodes.length === 0}
<div class="rounded-lg bg-theme-surface py-12 text-center shadow">
<p class="text-theme-text-secondary">Noch keine Charaktere vorhanden</p>
{#if data.user}
<a
href="/characters/new"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-theme-primary-600 hover:text-theme-primary-500"
>
Erstelle deinen ersten Charakter
</a>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each nodes as node}
<a
href="/characters/{node.slug}"
class="overflow-hidden rounded-lg bg-theme-surface shadow transition-shadow hover:shadow-md"
>
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium text-theme-text-primary">{node.title}</h3>
{#if node.summary}
<p class="mt-1 line-clamp-2 text-sm text-theme-text-secondary">{node.summary}</p>
{/if}
<div class="mt-3 flex items-center justify-between">
<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.tags && node.tags.length > 0}
<div class="flex space-x-1">
{#each node.tags.slice(0, 2) as tag}
<span
class="inline-flex items-center rounded bg-theme-primary-100 px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
</div>
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
async function loadCharacter() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Charakter nicht gefunden');
}
throw new Error('Fehler beim Laden des Charakters');
}
node = await response.json();
// Ensure it's a character
if (node && node.kind !== 'character') {
throw new Error('Dies ist kein Charakter');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteCharacter() {
if (!confirm('Möchtest du diesen Charakter wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
// Navigate back to appropriate page
if (node?.world_slug) {
goto(`/worlds/${node.world_slug}/characters`);
} else {
goto('/characters');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadCharacter();
});
</script>
<svelte:head>
<title>{node?.title || 'Charakter'} | 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">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/characters"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteCharacter}
editPath="/characters/{slug}/edit"
backPath={node.world_slug ? `/worlds/${node.world_slug}/characters` : '/characters'}
/>
{/if}

View file

@ -0,0 +1,384 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ContentNode, ContentData } from '$lib/types/content';
let { data } = $props();
if (!data.user) {
goto('/auth/login');
}
const slug = $page.params.slug;
let node = $state<ContentNode | null>(null);
let title = $state('');
let summary = $state('');
let visibility = $state<'private' | 'shared' | 'public'>('private');
let tags = $state('');
// Content fields
let appearance = $state('');
let lore = $state('');
let voice_style = $state('');
let capabilities = $state('');
let constraints = $state('');
let motivations = $state('');
let secrets = $state('');
let relationships_text = $state('');
let inventory_text = $state('');
let timeline_text = $state('');
let state_text = $state('');
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
async function loadCharacter() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Charakter nicht gefunden');
}
throw new Error('Fehler beim Laden des Charakters');
}
const loadedNode = await response.json();
node = loadedNode;
// Check ownership
if (!node || node.owner_id !== data.user?.id) {
throw new Error('Du hast keine Berechtigung, diesen Charakter zu bearbeiten');
}
// Populate form fields
title = node.title;
summary = node.summary || '';
visibility = node.visibility;
tags = node.tags?.join(', ') || '';
// Content fields
appearance = node.content.appearance || '';
lore = node.content.lore || '';
voice_style = node.content.voice_style || '';
capabilities = node.content.capabilities || '';
constraints = node.content.constraints || '';
motivations = node.content.motivations || '';
secrets = node.content.secrets || '';
relationships_text = node.content.relationships_text || '';
inventory_text = node.content.inventory_text || '';
timeline_text = node.content.timeline_text || '';
state_text = node.content.state_text || '';
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
saving = true;
error = null;
try {
const content: ContentData = {
appearance,
lore,
voice_style,
capabilities,
constraints,
motivations,
secrets,
relationships_text,
inventory_text,
timeline_text,
state_text,
};
const response = await fetch(`/api/nodes/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
summary,
visibility,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update character');
}
goto(`/characters/${slug}`);
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
saving = false;
}
}
$effect(() => {
loadCharacter();
});
</script>
<div class="mx-auto max-w-4xl">
{#if loading}
<div class="py-12 text-center">
<p class="text-theme-text-secondary">Lade Charakter...</p>
</div>
{:else if error && !node}
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/characters"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
Zurück zur Übersicht
</a>
</div>
{:else}
<div class="mb-6">
<h1 class="text-2xl font-bold text-theme-text-primary">Charakter bearbeiten</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Bearbeite die Details von {title}
</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
{#if error}
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<div class="grid grid-cols-1 gap-6 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}
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 sm:text-sm"
/>
</div>
<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 sm:text-sm"
>
<option value="private">Privat</option>
<option value="shared">Geteilt</option>
<option value="public">Öffentlich</option>
</select>
</div>
</div>
<div>
<label for="summary" class="block text-sm font-medium text-theme-text-primary">
Kurzbeschreibung
</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 sm:text-sm"
></textarea>
</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}
placeholder="fantasy, held, magier"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
<div class="border-t border-theme-border-subtle pt-6">
<h3 class="mb-4 text-lg font-medium text-theme-text-primary">Charakter-Details</h3>
<div class="space-y-6">
<div>
<label for="appearance" class="block text-sm font-medium text-theme-text-primary">
Aussehen
</label>
<textarea
id="appearance"
bind:value={appearance}
rows="3"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="lore" class="block text-sm font-medium text-theme-text-primary">
Hintergrundgeschichte
</label>
<textarea
id="lore"
bind:value={lore}
rows="4"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="voice_style" class="block text-sm font-medium text-theme-text-primary">
Sprechstil / Stimme
</label>
<textarea
id="voice_style"
bind:value={voice_style}
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 sm:text-sm"
></textarea>
</div>
<div>
<label for="capabilities" class="block text-sm font-medium text-theme-text-primary">
Fähigkeiten
</label>
<textarea
id="capabilities"
bind:value={capabilities}
rows="3"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="constraints" class="block text-sm font-medium text-theme-text-primary">
Grenzen & Regeln
</label>
<textarea
id="constraints"
bind:value={constraints}
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 sm:text-sm"
></textarea>
</div>
<div>
<label for="motivations" class="block text-sm font-medium text-theme-text-primary">
Motivationen & Ziele
</label>
<textarea
id="motivations"
bind:value={motivations}
rows="3"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="secrets" class="block text-sm font-medium text-theme-text-primary">
Geheimnisse
</label>
<textarea
id="secrets"
bind:value={secrets}
rows="2"
placeholder="Verborgene Informationen, die nicht jeder kennt"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label
for="relationships_text"
class="block text-sm font-medium text-theme-text-primary"
>
Beziehungen
</label>
<textarea
id="relationships_text"
bind:value={relationships_text}
rows="3"
placeholder="Beziehungen zu anderen Charakteren (nutze @slug für Referenzen)"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="inventory_text" class="block text-sm font-medium text-theme-text-primary">
Inventar / Besitz
</label>
<textarea
id="inventory_text"
bind:value={inventory_text}
rows="3"
placeholder="z.B. 'Trägt @excalibur und @schutzamulett'"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @objekt-slug um Objekte zu verlinken. Diese werden automatisch auf der
Charakterseite angezeigt.
</p>
</div>
<div>
<label for="timeline_text" class="block text-sm font-medium text-theme-text-primary">
Zeitlinie / Wichtige Ereignisse
</label>
<textarea
id="timeline_text"
bind:value={timeline_text}
rows="3"
placeholder="Chronologie wichtiger Ereignisse"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div>
<label for="state_text" class="block text-sm font-medium text-theme-text-primary">
Aktueller Zustand
</label>
<textarea
id="state_text"
bind:value={state_text}
rows="2"
placeholder="Wo befindet sich der Charakter gerade? Was ist sein aktueller Status?"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<a
href="/characters/{slug}"
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"
>
Abbrechen
</a>
<button
type="submit"
disabled={saving}
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"
>
{saving ? 'Wird gespeichert...' : 'Änderungen speichern'}
</button>
</div>
</form>
{/if}
</div>

View file

@ -0,0 +1,476 @@
<script lang="ts">
interface TableInfo {
name: string;
description: string;
columns: ColumnInfo[];
relationships: string[];
policies: string[];
indexes: string[];
}
interface ColumnInfo {
name: string;
type: string;
constraints: string[];
description: string;
}
// Database structure data
const enums = [
{
name: 'node_kind',
values: ['world', 'character', 'object', 'place', 'story'],
description: 'Content-Arten',
},
{
name: 'visibility_level',
values: ['private', 'shared', 'public'],
description: 'Sichtbarkeitsebenen',
},
{
name: 'story_entry_type',
values: ['narration', 'dialog', 'note'],
description: 'Story-Eintragstypen',
},
];
const tables: TableInfo[] = [
{
name: 'content_nodes',
description:
'Haupttabelle für alle Content-Entities (Welten, Charaktere, Orte, Objekte, Stories)',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'Eindeutige ID' },
{ name: 'kind', type: 'node_kind', constraints: ['NOT NULL'], description: 'Content-Art' },
{ name: 'slug', type: 'TEXT', constraints: ['UNIQUE'], description: 'URL-Identifier' },
{ name: 'title', type: 'TEXT', constraints: ['NOT NULL'], description: 'Name' },
{ name: 'summary', type: 'TEXT', constraints: [], description: 'Beschreibung' },
{ name: 'owner_id', type: 'UUID', constraints: ['FK'], description: 'Besitzer' },
{
name: 'visibility',
type: 'visibility_level',
constraints: [],
description: 'Sichtbarkeit',
},
{ name: 'world_slug', type: 'TEXT', constraints: ['FK'], description: 'Zugehörige Welt' },
{ name: 'content', type: 'JSONB', constraints: [], description: 'Flexibler Content' },
{ name: 'generation_prompt', type: 'TEXT', constraints: [], description: 'AI-Prompt' },
{ name: 'generation_model', type: 'TEXT', constraints: [], description: 'AI-Modell' },
{ name: 'image_url', type: 'TEXT', constraints: [], description: 'Hauptbild' },
{
name: 'search_tsv',
type: 'tsvector',
constraints: ['GENERATED'],
description: 'Volltext-Suche',
},
{ name: 'created_at', type: 'TIMESTAMPTZ', constraints: [], description: 'Erstellt' },
{ name: 'updated_at', type: 'TIMESTAMPTZ', constraints: [], description: 'Aktualisiert' },
],
relationships: ['auth.users (owner)', 'self-reference (world)'],
policies: ['Visibility-based access', 'Owner full control'],
indexes: ['kind', 'owner', 'visibility', 'world', 'tags (GIN)', 'search (GIN)'],
},
{
name: 'story_entries',
description: 'Einzelne Story-Einträge (Dialoge, Erzählung, Notizen)',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'story_slug', type: 'TEXT', constraints: ['FK', 'NOT NULL'], description: 'Story' },
{
name: 'position',
type: 'INTEGER',
constraints: ['NOT NULL'],
description: 'Reihenfolge',
},
{ name: 'type', type: 'story_entry_type', constraints: ['NOT NULL'], description: 'Typ' },
{ name: 'speaker_slug', type: 'TEXT', constraints: [], description: 'Sprecher' },
{ name: 'body', type: 'TEXT', constraints: ['NOT NULL'], description: 'Inhalt' },
{ name: 'created_by', type: 'UUID', constraints: ['FK'], description: 'Ersteller' },
{ name: 'created_at', type: 'TIMESTAMPTZ', constraints: [], description: 'Erstellt' },
],
relationships: ['content_nodes (story)', 'auth.users (creator)'],
policies: ['Inherits story visibility', 'Owner control'],
indexes: ['story', 'speaker'],
},
{
name: 'prompt_templates',
description: 'Wiederverwendbare AI-Prompt-Vorlagen',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'owner_id', type: 'UUID', constraints: ['FK'], description: 'Ersteller' },
{ name: 'world_slug', type: 'TEXT', constraints: ['FK'], description: 'Welt' },
{ name: 'kind', type: 'TEXT', constraints: ['NOT NULL'], description: 'Ziel-Art' },
{ name: 'title', type: 'TEXT', constraints: ['NOT NULL'], description: 'Name' },
{
name: 'prompt_template',
type: 'TEXT',
constraints: ['NOT NULL'],
description: 'Template',
},
{ name: 'usage_count', type: 'INTEGER', constraints: [], description: 'Verwendungen' },
{ name: 'is_public', type: 'BOOLEAN', constraints: [], description: 'Öffentlich' },
],
relationships: ['auth.users (owner)', 'content_nodes (world)'],
policies: ['Own templates + public templates'],
indexes: ['owner', 'world', 'kind', 'public'],
},
{
name: 'node_images',
description: 'Mehrere Bilder pro Content-Knoten',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'node_id', type: 'UUID', constraints: ['FK', 'NOT NULL'], description: 'Knoten' },
{ name: 'image_url', type: 'TEXT', constraints: ['NOT NULL'], description: 'Bild-URL' },
{ name: 'prompt', type: 'TEXT', constraints: [], description: 'AI-Prompt' },
{ name: 'is_primary', type: 'BOOLEAN', constraints: [], description: 'Hauptbild' },
{ name: 'sort_order', type: 'INTEGER', constraints: [], description: 'Sortierung' },
],
relationships: ['content_nodes (node)'],
policies: ['Inherits node visibility'],
indexes: ['node_id', 'is_primary', 'sort_order'],
},
{
name: 'attachments',
description: 'Dateianhänge für Content-Knoten',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'node_slug', type: 'TEXT', constraints: ['FK', 'NOT NULL'], description: 'Knoten' },
{ name: 'kind', type: 'TEXT', constraints: ['CHECK'], description: 'Dateityp' },
{ name: 'url', type: 'TEXT', constraints: ['NOT NULL'], description: 'Datei-URL' },
{ name: 'notes', type: 'TEXT', constraints: [], description: 'Notizen' },
],
relationships: ['content_nodes (node)'],
policies: ['Inherits node visibility'],
indexes: ['node'],
},
{
name: 'node_revisions',
description: 'Versionierung von Content-Änderungen',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'node_id', type: 'UUID', constraints: ['FK', 'NOT NULL'], description: 'Knoten' },
{ name: 'content_before', type: 'JSONB', constraints: [], description: 'Vorher' },
{ name: 'content_after', type: 'JSONB', constraints: [], description: 'Nachher' },
{ name: 'edited_by', type: 'UUID', constraints: ['FK'], description: 'Editor' },
{ name: 'edited_at', type: 'TIMESTAMPTZ', constraints: [], description: 'Zeitpunkt' },
],
relationships: ['content_nodes (node)', 'auth.users (editor)'],
policies: ['Inherits node visibility'],
indexes: [],
},
{
name: 'prompt_history',
description: 'Historie der AI-Prompt-Ausführungen',
columns: [
{ name: 'id', type: 'UUID', constraints: ['PK'], description: 'ID' },
{ name: 'user_id', type: 'UUID', constraints: ['FK'], description: 'Benutzer' },
{ name: 'node_id', type: 'UUID', constraints: ['FK'], description: 'Knoten' },
{ name: 'prompt', type: 'TEXT', constraints: ['NOT NULL'], description: 'Prompt' },
{ name: 'response', type: 'JSONB', constraints: [], description: 'Antwort' },
{ name: 'model', type: 'TEXT', constraints: [], description: 'Modell' },
],
relationships: ['auth.users (user)', 'content_nodes (node)'],
policies: ['Own history only'],
indexes: ['user', 'node'],
},
];
const functions = [
{ name: 'search_content_nodes', description: 'Full-Text-Suche mit Ranking' },
{ name: 'increment_template_usage', description: 'Template-Verwendungszähler' },
{ name: 'update_updated_at_column', description: 'Auto-Update Timestamps' },
{ name: 'ensure_single_primary_image', description: 'Ein Hauptbild pro Knoten' },
];
let selectedTable = $state<string | null>(null);
let viewMode = $state<'compact' | 'detailed'>('compact');
</script>
<div class="space-y-6">
<!-- Header with toggle -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-theme-text-primary mb-2">Datenbankstruktur</h1>
<p class="text-theme-text-secondary">
{tables.length} Tabellen • {enums.length} Enums • {functions.length} Funktionen • RLS aktiviert
</p>
</div>
<div class="flex items-center space-x-2">
<button
class="px-3 py-1 text-sm rounded-md transition-colors {viewMode === 'compact'
? 'bg-theme-primary-600 text-white'
: 'bg-theme-surface text-theme-text-secondary hover:text-theme-text-primary border border-theme-border-default'}"
onclick={() => (viewMode = 'compact')}
>
Kompakt
</button>
<button
class="px-3 py-1 text-sm rounded-md transition-colors {viewMode === 'detailed'
? 'bg-theme-primary-600 text-white'
: 'bg-theme-surface text-theme-text-secondary hover:text-theme-text-primary border border-theme-border-default'}"
onclick={() => (viewMode = 'detailed')}
>
Detailliert
</button>
</div>
</div>
<!-- Tables Section - Now at the top -->
<div class="space-y-4">
<h2 class="text-2xl font-semibold text-theme-text-primary">Tabellen</h2>
{#if viewMode === 'compact'}
<!-- Compact Grid View -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each tables as table}
<div
class="bg-theme-surface rounded-lg border border-theme-border-default p-4 hover:border-theme-border-subtle transition-colors"
>
<div class="mb-3">
<h3 class="font-mono font-semibold text-theme-text-primary mb-1">{table.name}</h3>
<p class="text-sm text-theme-text-secondary line-clamp-2">{table.description}</p>
</div>
<!-- Spalten - immer sichtbar -->
<div class="mb-4">
<h4 class="text-sm font-medium text-theme-text-primary mb-2">
Spalten ({table.columns.length})
</h4>
<div class="space-y-1">
{#each table.columns as column}
<div class="flex items-center justify-between text-xs">
<div class="flex items-center space-x-2">
<span class="font-mono text-theme-text-primary">{column.name}</span>
{#each column.constraints as constraint}
<span
class="px-1 py-0.5 text-xs bg-theme-interactive-subtle text-theme-text-secondary rounded"
>
{constraint}
</span>
{/each}
</div>
<span class="text-theme-text-secondary">{column.type}</span>
</div>
{/each}
</div>
</div>
<!-- Bottom info in drei nebeneinander -->
<div class="grid grid-cols-3 gap-2 text-xs">
<!-- Beziehungen -->
<div>
<h5 class="font-medium text-theme-text-primary mb-1">Beziehungen</h5>
{#if table.relationships.length > 0}
<div class="space-y-0.5">
{#each table.relationships as rel}
<div class="text-theme-text-secondary text-xs">{rel}</div>
{/each}
</div>
{:else}
<div class="text-theme-text-tertiary">Keine</div>
{/if}
</div>
<!-- Policies -->
<div>
<h5 class="font-medium text-theme-text-primary mb-1">Policies</h5>
{#if table.policies.length > 0}
<div class="space-y-0.5">
{#each table.policies as policy}
<div class="text-theme-text-secondary text-xs">{policy}</div>
{/each}
</div>
{:else}
<div class="text-theme-text-tertiary">Keine</div>
{/if}
</div>
<!-- Indizes -->
<div>
<h5 class="font-medium text-theme-text-primary mb-1">Indizes</h5>
{#if table.indexes.length > 0}
<div class="flex flex-wrap gap-1">
{#each table.indexes as index}
<span
class="px-1 py-0.5 text-xs bg-theme-interactive-subtle text-theme-text-secondary rounded"
>
{index}
</span>
{/each}
</div>
{:else}
<div class="text-theme-text-tertiary">Keine</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<!-- Detailed Table View -->
<div class="space-y-4">
{#each tables as table}
<div
class="bg-theme-surface rounded-lg border border-theme-border-default overflow-hidden"
>
<div class="p-4 border-b border-theme-border-subtle">
<h3 class="font-mono font-semibold text-theme-text-primary mb-1">{table.name}</h3>
<p class="text-sm text-theme-text-secondary">{table.description}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-theme-subtle">
<tr>
<th class="text-left p-3 font-medium text-theme-text-primary">Spalte</th>
<th class="text-left p-3 font-medium text-theme-text-primary">Typ</th>
<th class="text-left p-3 font-medium text-theme-text-primary">Constraints</th>
<th class="text-left p-3 font-medium text-theme-text-primary">Beschreibung</th>
</tr>
</thead>
<tbody>
{#each table.columns as column}
<tr class="border-t border-theme-border-subtle">
<td class="p-3 font-mono text-theme-text-primary">{column.name}</td>
<td class="p-3 text-theme-text-secondary">{column.type}</td>
<td class="p-3">
{#each column.constraints as constraint}
<span
class="inline-block px-1.5 py-0.5 text-xs bg-theme-interactive-subtle text-theme-text-secondary rounded mr-1 mb-1"
>
{constraint}
</span>
{/each}
</td>
<td class="p-3 text-theme-text-secondary">{column.description}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="p-4 bg-theme-subtle grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
{#if table.relationships.length > 0}
<div>
<h4 class="font-medium text-theme-text-primary mb-2">Beziehungen</h4>
<div class="space-y-1">
{#each table.relationships as rel}
<div class="text-theme-text-secondary">{rel}</div>
{/each}
</div>
</div>
{/if}
{#if table.policies.length > 0}
<div>
<h4 class="font-medium text-theme-text-primary mb-2">RLS-Richtlinien</h4>
<div class="space-y-1">
{#each table.policies as policy}
<div class="text-theme-text-secondary">{policy}</div>
{/each}
</div>
</div>
{/if}
{#if table.indexes.length > 0}
<div>
<h4 class="font-medium text-theme-text-primary mb-2">Indizes</h4>
<div class="flex flex-wrap gap-1">
{#each table.indexes as index}
<span
class="px-2 py-1 text-xs bg-theme-interactive-subtle text-theme-text-secondary rounded"
>
{index}
</span>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Bottom sections in compact layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Enums -->
<div>
<h2 class="text-xl font-semibold text-theme-text-primary mb-3">Enumerations</h2>
<div class="space-y-3">
{#each enums as enumInfo}
<div class="bg-theme-surface rounded-lg p-3 border border-theme-border-default">
<h3 class="font-mono font-semibold text-theme-text-primary mb-1">{enumInfo.name}</h3>
<p class="text-sm text-theme-text-secondary mb-2">{enumInfo.description}</p>
<div class="flex flex-wrap gap-1">
{#each enumInfo.values as value}
<span
class="inline-block px-2 py-1 text-xs bg-theme-primary-100 text-theme-primary-700 rounded"
>
{value}
</span>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Functions & Features -->
<div class="space-y-6">
<!-- Functions -->
<div>
<h2 class="text-xl font-semibold text-theme-text-primary mb-3">Funktionen</h2>
<div class="space-y-2">
{#each functions as func}
<div class="bg-theme-surface rounded-lg p-3 border border-theme-border-default">
<h3 class="font-mono font-medium text-theme-text-primary">{func.name}()</h3>
<p class="text-sm text-theme-text-secondary">{func.description}</p>
</div>
{/each}
</div>
</div>
<!-- Key Features -->
<div>
<h2 class="text-xl font-semibold text-theme-text-primary mb-3">Haupt-Features</h2>
<div class="grid grid-cols-2 gap-3">
<div
class="text-center p-3 bg-theme-surface rounded-lg border border-theme-border-default"
>
<div class="text-lg font-semibold text-theme-primary-600">FTS</div>
<div class="text-xs text-theme-text-secondary">Full-Text Search</div>
</div>
<div
class="text-center p-3 bg-theme-surface rounded-lg border border-theme-border-default"
>
<div class="text-lg font-semibold text-theme-primary-600">RLS</div>
<div class="text-xs text-theme-text-secondary">Row Level Security</div>
</div>
<div
class="text-center p-3 bg-theme-surface rounded-lg border border-theme-border-default"
>
<div class="text-lg font-semibold text-theme-primary-600">AI</div>
<div class="text-xs text-theme-text-secondary">Integration</div>
</div>
<div
class="text-center p-3 bg-theme-surface rounded-lg border border-theme-border-default"
>
<div class="text-lg font-semibold text-theme-primary-600">JSONB</div>
<div class="text-xs text-theme-text-secondary">Flexible Content</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import NodeList from '$lib/components/NodeList.svelte';
let { data } = $props();
</script>
<NodeList
kind="object"
kindLabel="Objekt"
kindLabelPlural="Objekte"
description="Verwalte wichtige Gegenstände und Artefakte deiner Welt"
user={data.user}
/>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
async function loadObject() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Objekt nicht gefunden');
}
throw new Error('Fehler beim Laden des Objekts');
}
node = await response.json();
// Ensure it's an object
if (node && node.kind !== 'object') {
throw new Error('Dies ist kein Objekt');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteObject() {
if (!confirm('Möchtest du dieses Objekt wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
// Navigate back to appropriate page
if (node?.world_slug) {
goto(`/worlds/${node.world_slug}/objects`);
} else {
goto('/objects');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadObject();
});
</script>
<svelte:head>
<title>{node?.title || 'Objekt'} | Worldream</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Objekt..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/objects"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteObject}
editPath="/objects/{slug}/edit"
backPath={node.world_slug ? `/worlds/${node.world_slug}/objects` : '/objects'}
/>
{/if}

View file

@ -0,0 +1,13 @@
<script lang="ts">
import NodeList from '$lib/components/NodeList.svelte';
let { data } = $props();
</script>
<NodeList
kind="place"
kindLabel="Ort"
kindLabelPlural="Orte"
description="Erschaffe magische Orte und Schauplätze für deine Geschichten"
user={data.user}
/>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
async function loadPlace() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Ort nicht gefunden');
}
throw new Error('Fehler beim Laden des Ortes');
}
node = await response.json();
// Ensure it's a place
if (node && node.kind !== 'place') {
throw new Error('Dies ist kein Ort');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deletePlace() {
if (!confirm('Möchtest du diesen Ort wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
// Navigate back to appropriate page
if (node?.world_slug) {
goto(`/worlds/${node.world_slug}/places`);
} else {
goto('/places');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadPlace();
});
</script>
<svelte:head>
<title>{node?.title || 'Ort'} | Worldream</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Ort..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/places"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deletePlace}
editPath="/places/{slug}/edit"
backPath={node.world_slug ? `/worlds/${node.world_slug}/places` : '/places'}
/>
{/if}

View file

@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
return {
user,
};
};

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { theme } from '$lib/themes/themeStore';
let { data } = $props();
let themeMode = $state<'light' | 'dark' | 'system'>('system');
$effect(() => {
const stored = localStorage.getItem('themeMode');
if (stored === 'light' || stored === 'dark' || stored === 'system') {
themeMode = stored;
} else {
themeMode = 'system';
}
});
function handleThemeChange(mode: 'light' | 'dark' | 'system') {
themeMode = mode;
localStorage.setItem('themeMode', mode);
if (mode === 'system') {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme.setTheme(systemPrefersDark ? 'dark' : 'light');
} else {
theme.setTheme(mode);
}
}
</script>
<div class="mx-auto max-w-2xl">
<h1 class="mb-8 text-2xl font-bold text-theme-text-primary">Einstellungen</h1>
<div class="divide-y divide-gray-200 rounded-lg bg-theme-surface shadow dark:divide-gray-700">
<div class="px-4 py-5 sm:p-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Konto</h2>
{#if data.user}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-theme-text-primary">E-Mail-Adresse</label>
<p class="mt-1 text-sm text-theme-text-primary">{data.user.email}</p>
</div>
<div>
<form method="POST" action="/auth/logout">
<button
type="submit"
class="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Abmelden
</button>
</form>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-secondary">Nicht angemeldet</p>
<a
href="/auth/login"
class="mt-2 inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-theme-primary-700 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
>
Anmelden
</a>
{/if}
</div>
<div class="px-4 py-5 sm:p-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Erscheinungsbild</h2>
<div class="space-y-4">
<label class="block text-sm font-medium text-theme-text-primary">Farbschema</label>
<div class="grid max-w-md grid-cols-3 gap-3">
<button
onclick={() => handleThemeChange('light')}
class="flex flex-col items-center justify-center rounded-lg border px-4 py-3 transition-all {themeMode ===
'light'
? 'border-violet-500 bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-slate-200 text-theme-text-primary hover:border-slate-300 dark:border-slate-600 dark:hover:border-slate-500'}"
>
<svg class="mb-2 h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span class="text-sm font-medium">Hell</span>
</button>
<button
onclick={() => handleThemeChange('dark')}
class="flex flex-col items-center justify-center rounded-lg border px-4 py-3 transition-all {themeMode ===
'dark'
? 'border-violet-500 bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-slate-200 text-theme-text-primary hover:border-slate-300 dark:border-slate-600 dark:hover:border-slate-500'}"
>
<svg class="mb-2 h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span class="text-sm font-medium">Dunkel</span>
</button>
<button
onclick={() => handleThemeChange('system')}
class="flex flex-col items-center justify-center rounded-lg border px-4 py-3 transition-all {themeMode ===
'system'
? 'border-violet-500 bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-slate-200 text-theme-text-primary hover:border-slate-300 dark:border-slate-600 dark:hover:border-slate-500'}"
>
<svg class="mb-2 h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span class="text-sm font-medium">System</span>
</button>
</div>
<p class="text-sm text-theme-text-secondary">
{#if themeMode === 'system'}
Verwendet die Systemeinstellung deines Geräts
{:else if themeMode === 'light'}
Helles Farbschema aktiviert
{:else}
Dunkles Farbschema aktiviert
{/if}
</p>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import NodeList from '$lib/components/NodeList.svelte';
let { data } = $props();
</script>
<NodeList
kind="story"
kindLabel="Story"
kindLabelPlural="Stories"
description="Erzähle Geschichten mit deinen Charakteren und Welten"
user={data.user}
/>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
async function loadStory() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Story nicht gefunden');
}
throw new Error('Fehler beim Laden der Story');
}
node = await response.json();
// Ensure it's a story
if (node && node.kind !== 'story') {
throw new Error('Dies ist keine Story');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteStory() {
if (!confirm('Möchtest du diese Story wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
// Navigate back to appropriate page
if (node?.world_slug) {
goto(`/worlds/${node.world_slug}/stories`);
} else {
goto('/stories');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadStory();
});
</script>
<svelte:head>
<title>{node?.title || 'Story'} | Worldream</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Story..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="bg-theme-error/10 rounded-md p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/stories"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteStory}
editPath="/stories/{slug}/edit"
backPath={node.world_slug ? `/worlds/${node.world_slug}/stories` : '/stories'}
/>
{/if}

View file

@ -0,0 +1,13 @@
<script lang="ts">
import NodeList from '$lib/components/NodeList.svelte';
let { data } = $props();
</script>
<NodeList
kind="world"
kindLabel="Welt"
kindLabelPlural="Welten"
description="Erschaffe und verwalte deine fiktionalen Welten"
user={data.user}
/>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
async function loadWorld() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Welt nicht gefunden');
}
throw new Error('Fehler beim Laden der Welt');
}
node = await response.json();
// Ensure it's a world
if (node && node.kind !== 'world') {
throw new Error('Dies ist keine Welt');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteWorld() {
if (!confirm('Möchtest du diese Welt wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
goto('/worlds');
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadWorld();
});
</script>
<svelte:head>
<title>{node?.title || 'Welt'} | Worldream</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Welt..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/worlds"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteWorld}
editPath="/worlds/{slug}/edit"
backPath="/worlds"
/>
{/if}

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ContentNode } from '$lib/types/content';
import NodeEditForm from '$lib/components/NodeEditForm.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
if (!data.user) {
goto('/auth/login');
}
const slug = $page.params.slug;
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
async function loadWorld() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Welt nicht gefunden');
}
throw new Error('Fehler beim Laden der Welt');
}
node = await response.json();
// Ensure it's a world
if (node && node.kind !== 'world') {
throw new Error('Dies ist keine Welt');
}
isOwner = data.user?.id === node?.owner_id;
if (!isOwner) {
throw new Error('Du hast keine Berechtigung, diese Welt zu bearbeiten');
}
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function handleSave(updatedNode: Partial<ContentNode>) {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNode),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Fehler beim Speichern');
}
const updated = await response.json();
goto(`/worlds/${updated.slug}`);
}
function handleCancel() {
goto(`/worlds/${slug}`);
}
$effect(() => {
loadWorld();
});
</script>
<svelte:head>
<title>{node?.title ? `${node.title} bearbeiten` : 'Welt bearbeiten'} | Worldream</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Welt..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/worlds"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node && isOwner}
<NodeEditForm {node} onSave={handleSave} onCancel={handleCancel} />
{/if}

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { currentWorld } from '$lib/stores/worldContext';
import { page } from '$app/stores';
import NodeCard from '$lib/components/NodeCard.svelte';
let { data } = $props();
let nodes = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadCharacters() {
if (!$currentWorld) {
error = 'Keine Welt ausgewählt';
loading = false;
return;
}
try {
const response = await fetch(`/api/nodes?kind=character&world_slug=${$currentWorld.slug}`);
if (!response.ok) throw new Error('Failed to load characters');
nodes = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
$effect(() => {
loadCharacters();
});
</script>
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-text-primary">Charaktere</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Charaktere in {$currentWorld?.title || 'dieser Welt'}
</p>
</div>
{#if data.user && $currentWorld}
<div class="mt-4 sm:mt-0">
<a
href="/worlds/{$currentWorld.slug}/characters/new"
class="inline-flex items-center 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"
>
<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 4v16m8-8H4"
/>
</svg>
Neuer Charakter
</a>
</div>
{/if}
</div>
{#if loading}
<div class="py-12 text-center">
<div
class="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-indigo-600 dark:border-violet-400"
></div>
<p class="mt-4 text-theme-text-secondary">Lade Charaktere...</p>
</div>
{:else if error}
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{:else if nodes.length === 0}
<div class="rounded-lg bg-theme-surface py-12 text-center shadow">
<svg
class="mx-auto h-12 w-12 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<p class="mt-4 text-theme-text-secondary">Noch keine Charaktere in dieser Welt</p>
{#if data.user && $currentWorld}
<a
href="/worlds/{$currentWorld.slug}/characters/new"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Erstelle den ersten Charakter
</a>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each nodes as node}
<NodeCard {node} href="/worlds/{$currentWorld?.slug}/characters/{node.slug}" />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { currentWorld } from '$lib/stores/worldContext';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
const worldSlug = $page.params.world;
async function loadCharacter() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Charakter nicht gefunden');
}
throw new Error('Fehler beim Laden des Charakters');
}
node = await response.json();
// 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;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteCharacter() {
if (!confirm('Möchtest du diesen Charakter wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
goto(`/worlds/${worldSlug}/characters`);
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadCharacter();
});
</script>
<svelte:head>
<title>{node?.title || 'Charakter'} | {$currentWorld?.title || '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">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/worlds/{worldSlug}/characters"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteCharacter}
editPath="/worlds/{worldSlug}/characters/{slug}/edit"
backPath="/worlds/{worldSlug}/characters"
/>
{/if}

View file

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

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { currentWorld } from '$lib/stores/worldContext';
import NodeForm from '$lib/components/forms/NodeForm.svelte';
import type { ContentNode } from '$lib/types/content';
let { data } = $props();
if (!data.user) {
goto('/auth/login');
}
if (!$currentWorld) {
goto('/');
}
async function handleSubmit(createdNode: ContentNode) {
goto(`/worlds/${$currentWorld!.slug}/characters/${createdNode.slug}`);
}
function handleCancel() {
goto(`/worlds/${$currentWorld!.slug}/characters`);
}
</script>
<div class="min-h-screen bg-theme-background">
<!-- Header Section with Gradient -->
<div
class="relative overflow-hidden bg-gradient-to-br from-theme-primary-500/10 via-theme-primary-600/5 to-transparent pb-8 pt-12"
>
<!-- Animated Background Elements -->
<div class="absolute inset-0 overflow-hidden">
<div
class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-theme-primary-400/10 blur-3xl"
></div>
<div
class="absolute -right-40 -bottom-40 h-80 w-80 rounded-full bg-theme-primary-600/10 blur-3xl"
></div>
</div>
<!-- Content -->
<div class="relative mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl font-bold tracking-tight text-theme-text-primary sm:text-5xl">
Neuen Charakter erstellen
</h1>
<p class="mt-4 text-lg text-theme-text-secondary">
Erschaffe einen einzigartigen Charakter für {$currentWorld?.title || 'deine Welt'}
</p>
</div>
</div>
</div>
<!-- Form Section -->
<div class="mx-auto max-w-4xl px-4 pb-12 sm:px-6 lg:px-8">
<div
class="relative -mt-4 rounded-2xl border border-theme-border-subtle bg-theme-surface shadow-xl"
>
<NodeForm
mode="create"
kind="character"
worldSlug={$currentWorld?.slug}
worldTitle={$currentWorld?.title}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
</div>
</div>
</div>
<style>
:global(body) {
background-color: var(--theme-background);
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { currentWorld } from '$lib/stores/worldContext';
import NodeCard from '$lib/components/NodeCard.svelte';
let { data } = $props();
let nodes = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadObjects() {
if (!$currentWorld) {
error = 'Keine Welt ausgewählt';
loading = false;
return;
}
try {
const response = await fetch(`/api/nodes?kind=object&world_slug=${$currentWorld.slug}`);
if (!response.ok) throw new Error('Failed to load objects');
nodes = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
$effect(() => {
loadObjects();
});
</script>
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-text-primary">Objekte</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Objekte in {$currentWorld?.title || 'dieser Welt'}
</p>
</div>
{#if data.user && $currentWorld}
<div class="mt-4 sm:mt-0">
<a
href="/worlds/{$currentWorld.slug}/objects/new"
class="inline-flex items-center 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"
>
<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 4v16m8-8H4"
/>
</svg>
Neues Objekt
</a>
</div>
{/if}
</div>
{#if loading}
<div class="py-12 text-center">
<div
class="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-indigo-600 dark:border-violet-400"
></div>
<p class="mt-4 text-theme-text-secondary">Lade Objekte...</p>
</div>
{:else if error}
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{:else if nodes.length === 0}
<div class="rounded-lg bg-theme-surface py-12 text-center shadow">
<svg
class="mx-auto h-12 w-12 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<p class="mt-4 text-theme-text-secondary">Noch keine Objekte in dieser Welt</p>
{#if data.user && $currentWorld}
<a
href="/worlds/{$currentWorld.slug}/objects/new"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Erstelle das erste Objekt
</a>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each nodes as node}
<NodeCard {node} href="/worlds/{$currentWorld?.slug}/objects/{node.slug}" />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { currentWorld } from '$lib/stores/worldContext';
import NodeDetail from '$lib/components/NodeDetail.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let { data } = $props();
let node = $state<ContentNode | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let isOwner = $state(false);
const slug = $page.params.slug;
const worldSlug = $page.params.world;
async function loadObject() {
try {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Objekt nicht gefunden');
}
throw new Error('Fehler beim Laden des Objekts');
}
node = await response.json();
// Ensure it's an object and belongs to this world
if (node && node.kind !== 'object') {
throw new Error('Dies ist kein Objekt');
}
if (node && node.world_slug !== worldSlug) {
throw new Error('Dieses Objekt gehört nicht zu dieser Welt');
}
isOwner = data.user?.id === node?.owner_id;
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
async function deleteObject() {
if (!confirm('Möchtest du dieses Objekt wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Fehler beim Löschen');
goto(`/worlds/${worldSlug}/objects`);
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
}
}
$effect(() => {
loadObject();
});
</script>
<svelte:head>
<title>{node?.title || 'Objekt'} | {$currentWorld?.title || 'Worldream'}</title>
</svelte:head>
{#if loading}
<LoadingOverlay message="Lade Objekt..." />
{:else if error}
<div class="mx-auto max-w-4xl">
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
<a
href="/worlds/{worldSlug}/objects"
class="mt-2 inline-block text-sm text-theme-primary-600 hover:text-violet-500 dark:hover:text-violet-300"
>
Zurück zur Übersicht
</a>
</div>
</div>
{:else if node}
<NodeDetail
{node}
{isOwner}
onDelete={deleteObject}
editPath="/worlds/{worldSlug}/objects/{slug}/edit"
backPath="/worlds/{worldSlug}/objects"
/>
{/if}

Some files were not shown because too many files have changed in this diff Show more