mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 16:06:41 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
36
games/worldream/apps/web/src/app.css
Normal file
36
games/worldream/apps/web/src/app.css
Normal 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
20
games/worldream/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
11
games/worldream/apps/web/src/app.html
Normal file
11
games/worldream/apps/web/src/app.html
Normal 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>
|
||||
41
games/worldream/apps/web/src/hooks.server.ts
Normal file
41
games/worldream/apps/web/src/hooks.server.ts
Normal 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';
|
||||
},
|
||||
});
|
||||
};
|
||||
266
games/worldream/apps/web/src/lib/ai/editing.ts
Normal file
266
games/worldream/apps/web/src/lib/ai/editing.ts
Normal 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;
|
||||
}
|
||||
162
games/worldream/apps/web/src/lib/ai/gemini.ts
Normal file
162
games/worldream/apps/web/src/lib/ai/gemini.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
441
games/worldream/apps/web/src/lib/ai/openai-streaming.ts
Normal file
441
games/worldream/apps/web/src/lib/ai/openai-streaming.ts
Normal 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
|
||||
},
|
||||
};
|
||||
}
|
||||
564
games/worldream/apps/web/src/lib/ai/openai.ts
Normal file
564
games/worldream/apps/web/src/lib/ai/openai.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
195
games/worldream/apps/web/src/lib/ai/replicate-flux.ts
Normal file
195
games/worldream/apps/web/src/lib/ai/replicate-flux.ts
Normal 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;
|
||||
}
|
||||
1
games/worldream/apps/web/src/lib/assets/favicon.svg
Normal file
1
games/worldream/apps/web/src/lib/assets/favicon.svg
Normal 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 |
166
games/worldream/apps/web/src/lib/components/AiFieldHelper.svelte
Normal file
166
games/worldream/apps/web/src/lib/components/AiFieldHelper.svelte
Normal 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>
|
||||
200
games/worldream/apps/web/src/lib/components/AiGenerator.svelte
Normal file
200
games/worldream/apps/web/src/lib/components/AiGenerator.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
import type { NodeKind, ContentData } from '$lib/types/content';
|
||||
|
||||
interface Props {
|
||||
kind: NodeKind;
|
||||
onGenerated: (data: {
|
||||
title: string;
|
||||
summary: string;
|
||||
content: Partial<ContentData>;
|
||||
tags: string[];
|
||||
}) => void;
|
||||
context?: {
|
||||
world?: string;
|
||||
existingCharacters?: string[];
|
||||
existingPlaces?: string[];
|
||||
existingObjects?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
let { kind, onGenerated, context }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let prompt = $state('');
|
||||
let generating = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const kindLabels: Record<NodeKind, string> = {
|
||||
character: 'Charakter',
|
||||
world: 'Welt',
|
||||
place: 'Ort',
|
||||
object: 'Objekt',
|
||||
story: 'Story',
|
||||
};
|
||||
|
||||
const placeholders: Record<NodeKind, string> = {
|
||||
character: 'Ein weiser alter Magier mit einem Geheimnis...',
|
||||
world: 'Eine düstere Cyberpunk-Welt mit magischen Elementen...',
|
||||
place: 'Ein mysteriöser Wald, in dem die Zeit anders verläuft...',
|
||||
object: 'Ein Amulett, das seinem Träger besondere Kräfte verleiht...',
|
||||
story: 'Eine Heldenreise, bei der ungleiche Gefährten zusammenfinden...',
|
||||
};
|
||||
|
||||
async function generate() {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
generating = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind,
|
||||
prompt,
|
||||
context,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Generierung fehlgeschlagen');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
onGenerated(result);
|
||||
isOpen = false;
|
||||
prompt = '';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDialog() {
|
||||
isOpen = !isOpen;
|
||||
if (!isOpen) {
|
||||
prompt = '';
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDialog}
|
||||
class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium leading-4 text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
KI-Generierung
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0"
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-slate-500 bg-opacity-75 transition-opacity"
|
||||
onclick={toggleDialog}
|
||||
></div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div
|
||||
class="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-violet-100"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-violet-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3 class="text-lg font-medium leading-6 text-slate-900">
|
||||
{kindLabels[kind]} mit KI generieren
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-slate-500">
|
||||
Beschreibe, was du erstellen möchtest. Die KI generiert dann alle Details für
|
||||
dich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
bind:value={prompt}
|
||||
disabled={generating}
|
||||
rows="4"
|
||||
placeholder={placeholders[kind]}
|
||||
class="w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50 sm:text-sm"
|
||||
></textarea>
|
||||
|
||||
{#if context}
|
||||
<div class="mt-2 text-xs text-slate-500">
|
||||
{#if context.world}
|
||||
<p>Welt: {context.world}</p>
|
||||
{/if}
|
||||
{#if context.existingCharacters?.length}
|
||||
<p>Verfügbare Charaktere: {context.existingCharacters.slice(0, 3).join(', ')}</p>
|
||||
{/if}
|
||||
{#if context.existingPlaces?.length}
|
||||
<p>Verfügbare Orte: {context.existingPlaces.slice(0, 3).join(', ')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={generate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
class="inline-flex w-full justify-center rounded-md border border-transparent bg-violet-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-2 sm:text-sm"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDialog}
|
||||
disabled={generating}
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-1 sm:mt-0 sm:text-sm"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
<script lang="ts">
|
||||
import type { NodeKind } from '$lib/types/content';
|
||||
|
||||
interface Props {
|
||||
kind?: NodeKind;
|
||||
title?: string;
|
||||
description?: string;
|
||||
appearance?: string;
|
||||
prompt?: string;
|
||||
imagePrompt?: string;
|
||||
imageUrl?: string | null;
|
||||
onImageGenerated?: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
kind = 'character',
|
||||
title = '',
|
||||
description = '',
|
||||
appearance = '',
|
||||
prompt = $bindable(''),
|
||||
imagePrompt = $bindable(''),
|
||||
imageUrl = $bindable(null),
|
||||
onImageGenerated,
|
||||
}: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let translating = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let generatedImageUrl = $state<string | null>(null);
|
||||
let selectedStyle = $state<'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration'>(
|
||||
'fantasy'
|
||||
);
|
||||
let showOptions = $state(false);
|
||||
|
||||
// Extract title and description from prompt if provided
|
||||
$effect(() => {
|
||||
if (prompt) {
|
||||
const parts = prompt.split(':');
|
||||
if (parts.length > 0 && !title) {
|
||||
title = parts[0].trim();
|
||||
}
|
||||
if (parts.length > 1 && !description && !appearance) {
|
||||
description = parts.slice(1).join(':').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Determine aspect ratio based on kind
|
||||
function getAspectRatio() {
|
||||
switch (kind) {
|
||||
case 'world':
|
||||
case 'place':
|
||||
return '16:9'; // Widescreen for worlds and places
|
||||
case 'object':
|
||||
return '1:1'; // Square for objects
|
||||
case 'character':
|
||||
return '9:16'; // Portrait for characters
|
||||
default:
|
||||
return '1:1'; // Default to square
|
||||
}
|
||||
}
|
||||
|
||||
// Get CSS class for image display based on aspect ratio
|
||||
function getImageClass() {
|
||||
const aspectRatio = getAspectRatio();
|
||||
switch (aspectRatio) {
|
||||
case '21:9':
|
||||
return 'w-full aspect-[21/9]'; // 21:9 ultrawide aspect ratio
|
||||
case '16:9':
|
||||
return 'w-full aspect-video'; // 16:9 aspect ratio
|
||||
case '9:16':
|
||||
return 'w-64 mx-auto aspect-[9/16]'; // 9:16 aspect ratio, centered
|
||||
case '1:1':
|
||||
default:
|
||||
return 'w-full max-w-md mx-auto aspect-square'; // 1:1 aspect ratio
|
||||
}
|
||||
}
|
||||
|
||||
async function translateToEnglish() {
|
||||
const germanText = appearance || description;
|
||||
|
||||
if (!germanText || germanText.length < 10) {
|
||||
error = 'Keine deutsche Beschreibung zum Übersetzen vorhanden';
|
||||
return;
|
||||
}
|
||||
|
||||
translating = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/translate-image-prompt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
germanDescription: germanText,
|
||||
kind,
|
||||
title: title || 'Unbenannt',
|
||||
style: selectedStyle,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Übersetzung fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.englishPrompt) {
|
||||
imagePrompt = data.englishPrompt;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Translation error:', err);
|
||||
error = err instanceof Error ? err.message : 'Übersetzung fehlgeschlagen';
|
||||
} finally {
|
||||
translating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateImage() {
|
||||
const effectiveTitle = title || prompt?.split(':')[0]?.trim();
|
||||
const effectiveDescription =
|
||||
description || appearance || prompt?.split(':').slice(1).join(':')?.trim();
|
||||
|
||||
if (!effectiveTitle) {
|
||||
error = 'Titel ist erforderlich für die Bildgenerierung';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/generate-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
kind,
|
||||
title: effectiveTitle,
|
||||
description: imagePrompt || effectiveDescription,
|
||||
style: selectedStyle,
|
||||
aspectRatio: getAspectRatio(),
|
||||
context: {
|
||||
appearance: imagePrompt || appearance || effectiveDescription,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Bildgenerierung fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response von API:', data); // Debug-Log
|
||||
|
||||
if (data.imageUrl) {
|
||||
generatedImageUrl = data.imageUrl;
|
||||
imageUrl = data.imageUrl; // Update the bound prop
|
||||
onImageGenerated?.(data.imageUrl);
|
||||
console.log('Bild-URL gesetzt:', generatedImageUrl); // Debug-Log
|
||||
}
|
||||
|
||||
imagePrompt = data.prompt;
|
||||
prompt = data.prompt; // Update the bound prompt prop
|
||||
|
||||
// Zeige Info-Message wenn Bild noch nicht verfügbar
|
||||
if (!data.imageUrl && data.message) {
|
||||
error = data.message;
|
||||
console.log('Kein Bild, Nachricht:', data.message); // Debug-Log
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler:', err);
|
||||
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetImage() {
|
||||
generatedImageUrl = null;
|
||||
imagePrompt = null;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-slate-900">Bild generieren</h3>
|
||||
{#if !generatedImageUrl}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
class="text-sm text-violet-600 hover:text-violet-500"
|
||||
>
|
||||
{showOptions ? 'Optionen ausblenden' : 'Optionen anzeigen'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showOptions && !generatedImageUrl}
|
||||
<div class="space-y-3 rounded-md bg-slate-50 p-3">
|
||||
<div>
|
||||
<label for="style" class="block text-sm font-medium text-slate-700"> Bildstil </label>
|
||||
<select
|
||||
id="style"
|
||||
bind:value={selectedStyle}
|
||||
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
|
||||
>
|
||||
<option value="fantasy">Fantasy</option>
|
||||
<option value="realistic">Realistisch</option>
|
||||
<option value="anime">Anime</option>
|
||||
<option value="concept-art">Concept Art</option>
|
||||
<option value="illustration">Illustration</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500">
|
||||
Das Bild wird basierend auf dem Titel und der Beschreibung generiert.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Deutsche Beschreibung und Übersetzung -->
|
||||
{#if appearance && !generatedImageUrl}
|
||||
<div class="space-y-3 rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<div>
|
||||
<h4 class="mb-2 text-sm font-medium text-slate-700">Deutsche Beschreibung:</h4>
|
||||
<p class="rounded border bg-white p-2 text-sm text-slate-600">{appearance}</p>
|
||||
</div>
|
||||
|
||||
{#if !imagePrompt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={translateToEnglish}
|
||||
disabled={translating}
|
||||
class="flex w-full items-center justify-center rounded-md border border-blue-300 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 shadow-sm hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if translating}
|
||||
<svg
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Übersetze...
|
||||
{:else}
|
||||
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||
/>
|
||||
</svg>
|
||||
Ins Englische übersetzen
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div>
|
||||
<h4 class="mb-2 flex items-center text-sm font-medium text-green-700">
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Englischer Bild-Prompt:
|
||||
</h4>
|
||||
<p class="rounded border border-green-200 bg-green-50 p-2 text-sm text-slate-600">
|
||||
{imagePrompt}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if generatedImageUrl}
|
||||
<div class="relative">
|
||||
<img
|
||||
src={generatedImageUrl}
|
||||
alt={`Generiertes Bild für ${title}`}
|
||||
class="{getImageClass()} rounded-lg object-cover shadow-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetImage}
|
||||
class="bg-theme-surface/90 absolute right-2 top-2 rounded-full p-2 shadow-lg backdrop-blur-sm transition-all hover:bg-theme-surface"
|
||||
title="Neues Bild generieren"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={generateImage}
|
||||
disabled={loading || (!title && !prompt) || (appearance && !imagePrompt)}
|
||||
class="border-theme-border-default flex w-full items-center justify-center rounded-md border bg-theme-surface px-4 py-3 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="-ml-1 mr-3 h-5 w-5 animate-spin text-theme-text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Generiere Bild...
|
||||
{:else if appearance && !imagePrompt}
|
||||
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 15.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
Bitte zuerst deutsche Beschreibung übersetzen
|
||||
{:else}
|
||||
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Bild mit KI generieren
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-md bg-yellow-50/50 p-3">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-theme-warning">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if imagePrompt}
|
||||
<details class="text-xs text-theme-text-secondary">
|
||||
<summary class="cursor-pointer hover:text-theme-text-primary">Verwendeter Prompt</summary>
|
||||
<p class="mt-2 rounded bg-theme-elevated p-2 font-mono text-xs text-theme-text-secondary">
|
||||
{imagePrompt}
|
||||
</p>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
343
games/worldream/apps/web/src/lib/components/AiPromptField.svelte
Normal file
343
games/worldream/apps/web/src/lib/components/AiPromptField.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,607 @@
|
|||
<script lang="ts">
|
||||
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
|
||||
import AiImageGenerator from './AiImageGenerator.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { NodeKind } from '$lib/types/content';
|
||||
|
||||
let command = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state<string | null>(null);
|
||||
let processingCommands = $state<Set<string>>(new Set());
|
||||
|
||||
// Image generation state
|
||||
let imagePrompt = $state('');
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let generatedPrompt = $state<string | null>(null);
|
||||
|
||||
// Subscribe to store
|
||||
let aiState = $state({
|
||||
isVisible: false,
|
||||
currentNode: null as any,
|
||||
isOwner: false,
|
||||
mode: 'text' as 'text' | 'image',
|
||||
imageGenerationState: {
|
||||
loading: false,
|
||||
generatedUrl: null as string | null,
|
||||
prompt: '',
|
||||
style: 'fantasy' as any,
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = aiAuthorStore.subscribe((state) => {
|
||||
console.log('🌟 GlobalAiAuthorBar: Store update', state);
|
||||
aiState = state;
|
||||
|
||||
// Auto-populate image prompt from node appearance
|
||||
if (state.mode === 'image' && state.currentNode && !imagePrompt) {
|
||||
const node = state.currentNode;
|
||||
imagePrompt = node.content?.appearance || node.summary || '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-hide success/error messages
|
||||
let successTimeout: ReturnType<typeof setTimeout>;
|
||||
let errorTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function showSuccess(message: string) {
|
||||
success = message;
|
||||
clearTimeout(successTimeout);
|
||||
successTimeout = setTimeout(() => {
|
||||
success = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
error = message;
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
error = null;
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
async function executeCommand() {
|
||||
const currentCommand = command.trim();
|
||||
if (!currentCommand || processingCommands.has(currentCommand) || !aiState.currentNode) return;
|
||||
|
||||
// Add to processing queue
|
||||
processingCommands.add(currentCommand);
|
||||
processingCommands = new Set(processingCommands);
|
||||
|
||||
// Clear input immediately for better UX
|
||||
command = '';
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Show processing feedback
|
||||
showSuccess(
|
||||
`🔄 Bearbeite: "${currentCommand.substring(0, 50)}${currentCommand.length > 50 ? '...' : ''}"`
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/edit-node', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
nodeSlug: aiState.currentNode.slug,
|
||||
command: currentCommand,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Fehler beim Bearbeiten');
|
||||
}
|
||||
|
||||
if (data.success && data.updatedNode) {
|
||||
aiAuthorStore.updateNode(data.updatedNode);
|
||||
showSuccess(`✅ Erfolgreich bearbeitet: "${currentCommand.substring(0, 30)}..."`);
|
||||
|
||||
// Dispatch custom event to notify components
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('node-updated', {
|
||||
detail: { updatedNode: data.updatedNode },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error('Unexpected response format');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Ein unerwarteter Fehler ist aufgetreten';
|
||||
showError(`❌ Fehler: ${errorMessage}`);
|
||||
} finally {
|
||||
// Remove from processing queue
|
||||
processingCommands.delete(currentCommand);
|
||||
processingCommands = new Set(processingCommands);
|
||||
loading = processingCommands.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageGenerated(url: string) {
|
||||
imageUrl = url;
|
||||
aiAuthorStore.setImageState({ generatedUrl: url });
|
||||
await saveGeneratedImage();
|
||||
}
|
||||
|
||||
async function saveGeneratedImage() {
|
||||
if (!imageUrl || !aiState.currentNode) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Use the proper attachments-based endpoint to save image
|
||||
const response = await fetch(`/api/nodes/${aiState.currentNode.slug}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_url: imageUrl,
|
||||
prompt: generatedPrompt || imagePrompt,
|
||||
is_primary: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
|
||||
}
|
||||
|
||||
showSuccess('🖼️ Bild erfolgreich gespeichert!');
|
||||
|
||||
// Reset image state
|
||||
imageUrl = null;
|
||||
generatedPrompt = null;
|
||||
aiAuthorStore.resetImageState();
|
||||
|
||||
// Notify components to reload images
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('images-updated', {
|
||||
detail: { nodeSlug: aiState.currentNode.slug },
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Only handle global shortcuts when author bar is focused
|
||||
if (e.target && (e.target as HTMLElement).closest('#global-ai-author-bar')) {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (aiState.mode === 'text') {
|
||||
executeCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global escape to close
|
||||
if (e.key === 'Escape' && aiState.isVisible) {
|
||||
aiAuthorStore.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
aiAuthorStore.toggle();
|
||||
if (aiState.isVisible) {
|
||||
// Focus the textarea when shown
|
||||
setTimeout(() => {
|
||||
if (aiState.mode === 'text') {
|
||||
const textarea = document.querySelector(
|
||||
'#global-ai-command-input'
|
||||
) as HTMLTextAreaElement;
|
||||
textarea?.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(mode: 'text' | 'image') {
|
||||
aiAuthorStore.setMode(mode);
|
||||
error = null;
|
||||
success = null;
|
||||
}
|
||||
|
||||
// Command suggestions based on node type
|
||||
function getSuggestions() {
|
||||
if (!aiState.currentNode) return [];
|
||||
|
||||
const suggestions = {
|
||||
character: [
|
||||
'Benenne um zu Maximilian der Große',
|
||||
'Füge zur Erscheinung hinzu: trägt einen roten Mantel',
|
||||
'Ändere die Fähigkeiten zu: Meister der Feuermagie',
|
||||
'Aktualisiere das Inventar: trägt @magisches-schwert',
|
||||
],
|
||||
place: [
|
||||
'Benenne um zu Die goldene Stadt',
|
||||
'Füge zur Geschichte hinzu: wurde vor 100 Jahren erbaut',
|
||||
'Ändere die Gefahren zu: wilde Kreaturen in der Nacht',
|
||||
'Aktualisiere den Zustand: jetzt in Ruinen',
|
||||
],
|
||||
object: [
|
||||
'Benenne um zu Schwert der Macht',
|
||||
'Füge zu den Fähigkeiten hinzu: kann Feinde blenden',
|
||||
'Ändere den Besitzer zu: gehört jetzt @aragorn',
|
||||
'Aktualisiere die Erscheinung: glänzt in blauem Licht',
|
||||
],
|
||||
world: [
|
||||
'Benenne um zu Reich der tausend Sonnen',
|
||||
'Füge zur Geschichte hinzu: geprägt von magischen Kriegen',
|
||||
'Aktualisiere die Regeln: Magie ist verboten',
|
||||
'Ändere die Zeitlinie: Das große Erwachen im Jahr 2157',
|
||||
],
|
||||
story: [
|
||||
'Benenne um zu Das letzte Abenteuer',
|
||||
'Füge zum Plot hinzu: die Helden treffen auf einen Drachen',
|
||||
'Ändere die Referenzen zu: @mira, @dunkler-turm, @zauberring',
|
||||
'Aktualisiere den Verlauf: endet mit einem Cliffhanger',
|
||||
],
|
||||
};
|
||||
return suggestions[aiState.currentNode.kind as NodeKind] || [];
|
||||
}
|
||||
|
||||
let suggestions = $derived(getSuggestions());
|
||||
</script>
|
||||
|
||||
<!-- Floating Toast Notifications -->
|
||||
<div class="fixed right-4 top-20 z-50 space-y-2">
|
||||
{#if success}
|
||||
<div
|
||||
transition:fly={{ x: 100, duration: 300 }}
|
||||
class="max-w-sm rounded-lg border border-theme-border-subtle bg-theme-surface shadow-lg"
|
||||
>
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex-shrink-0">
|
||||
{#if success.includes('🔄')}
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-theme-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-theme-success" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ml-3 text-sm text-theme-text-primary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
transition:fly={{ x: 100, duration: 300 }}
|
||||
class="max-w-sm rounded-lg border border-theme-error/20 bg-theme-error/10 shadow-lg"
|
||||
>
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-theme-error" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-3 text-sm text-theme-error">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Global Floating Toggle Button -->
|
||||
{#if aiState.currentNode && aiState.isOwner}
|
||||
<button
|
||||
onclick={toggleVisibility}
|
||||
class="fixed bottom-4 right-4 z-40 rounded-full bg-gradient-to-br from-theme-primary-500 to-theme-primary-600 p-3 text-white shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-110 {aiState.isVisible
|
||||
? 'rotate-45'
|
||||
: ''} {loading ? 'animate-pulse' : ''}"
|
||||
title="AI Author Bar {aiState.isVisible ? 'schließen' : 'öffnen'}"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{#if loading}
|
||||
<div class="absolute -right-1 -top-1 h-3 w-3">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-theme-primary-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-3 w-3 rounded-full bg-theme-primary-500"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Global Author Bar -->
|
||||
{#if aiState.currentNode && aiState.isOwner}
|
||||
<div
|
||||
id="global-ai-author-bar"
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t border-theme-border-default bg-theme-surface/95 backdrop-blur-md shadow-2xl transition-transform duration-300 {aiState.isVisible
|
||||
? 'translate-y-0'
|
||||
: 'translate-y-full'}"
|
||||
>
|
||||
<div class="mx-auto max-w-4xl p-4">
|
||||
<!-- Header with Tabs -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-3 w-3 rounded-full {loading
|
||||
? 'bg-theme-primary-500 animate-pulse'
|
||||
: 'bg-theme-success'}"
|
||||
></div>
|
||||
{#if processingCommands.size > 0}
|
||||
<div
|
||||
class="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-theme-primary-600 text-[10px] text-white"
|
||||
>
|
||||
{processingCommands.size}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-theme-text-primary">✨ AI Author</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex rounded-lg bg-theme-elevated p-0.5">
|
||||
<button
|
||||
onclick={() => switchMode('text')}
|
||||
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
|
||||
'text'
|
||||
? 'bg-theme-surface text-theme-text-primary shadow-sm'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Text</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => switchMode('image')}
|
||||
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
|
||||
'image'
|
||||
? 'bg-theme-surface text-theme-text-primary shadow-sm'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Bild</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => aiAuthorStore.hide()}
|
||||
class="p-1 text-theme-text-secondary transition-colors hover:text-theme-text-primary"
|
||||
title="Schließen (Esc)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
{#if aiState.mode === 'text'}
|
||||
<!-- Text Edit Mode -->
|
||||
<div class="space-y-3">
|
||||
<!-- Command Input -->
|
||||
<div class="relative">
|
||||
<textarea
|
||||
id="global-ai-command-input"
|
||||
bind:value={command}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="z.B. 'Benenne um zu Maximilian der Große' oder 'Füge zur Erscheinung hinzu: trägt eine goldene Krone'"
|
||||
rows="2"
|
||||
class="w-full resize-none rounded-md border border-theme-border-default bg-theme-background pr-20 text-sm shadow-sm transition-all focus:border-theme-primary-500 focus:ring-2 focus:ring-theme-primary-500/20 {loading
|
||||
? 'pl-10'
|
||||
: ''}"
|
||||
></textarea>
|
||||
{#if loading}
|
||||
<div class="absolute left-3 top-3">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin text-theme-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute bottom-1 right-2 text-xs text-theme-text-secondary">⌘+Enter</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Suggestions -->
|
||||
{#if suggestions.length > 0 && !command.trim()}
|
||||
<div class="scrollbar-thin flex gap-2 overflow-x-auto pb-1">
|
||||
{#each suggestions as suggestion}
|
||||
<button
|
||||
onclick={() => (command = suggestion)}
|
||||
class="flex-shrink-0 whitespace-nowrap rounded-full border border-theme-border-default bg-theme-elevated px-3 py-1 text-xs transition-all hover:bg-theme-interactive-hover hover:shadow-md"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Processing Queue Display -->
|
||||
{#if processingCommands.size > 0}
|
||||
<div class="rounded-lg bg-theme-primary-500/10 p-2">
|
||||
<p class="mb-1 text-xs font-medium text-theme-text-secondary">
|
||||
Verarbeite {processingCommands.size} Befehl{processingCommands.size !== 1
|
||||
? 'e'
|
||||
: ''}:
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
{#each Array.from(processingCommands) as cmd}
|
||||
<div class="flex items-center space-x-2 text-xs text-theme-text-secondary">
|
||||
<svg
|
||||
class="h-3 w-3 animate-spin text-theme-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="truncate"
|
||||
>{cmd.substring(0, 50)}{cmd.length > 50 ? '...' : ''}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-theme-text-secondary">
|
||||
<span class="inline-flex items-center">
|
||||
<span
|
||||
class="mr-1 h-2 w-2 rounded-full {loading
|
||||
? 'animate-pulse bg-theme-primary-500'
|
||||
: 'bg-theme-success'}"
|
||||
></span>
|
||||
{loading
|
||||
? `Verarbeite ${processingCommands.size} Befehl${processingCommands.size !== 1 ? 'e' : ''}...`
|
||||
: 'AI bereit'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
onclick={() => aiAuthorStore.hide()}
|
||||
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-all hover:bg-theme-interactive-hover hover:shadow-md"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
<button
|
||||
onclick={executeCommand}
|
||||
disabled={!command.trim()}
|
||||
class="flex items-center space-x-2 rounded bg-gradient-to-r from-theme-primary-500 to-theme-primary-600 px-4 py-1.5 text-sm text-white transition-all hover:from-theme-primary-600 hover:to-theme-primary-700 hover:shadow-lg disabled:opacity-50"
|
||||
>
|
||||
<span>✨ Mit AI bearbeiten</span>
|
||||
{#if loading}
|
||||
<span class="text-xs opacity-75">({processingCommands.size})</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Image Generation Mode -->
|
||||
<div class="space-y-4">
|
||||
{#if aiState.currentNode}
|
||||
<AiImageGenerator
|
||||
kind={aiState.currentNode.kind}
|
||||
title={aiState.currentNode.title}
|
||||
description={aiState.currentNode.summary}
|
||||
appearance={aiState.currentNode.content?.appearance}
|
||||
bind:imageUrl
|
||||
bind:prompt={generatedPrompt}
|
||||
onImageGenerated={handleImageGenerated}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if imageUrl}
|
||||
<div class="flex justify-end space-x-2 border-t border-theme-border-subtle pt-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
imageUrl = null;
|
||||
generatedPrompt = null;
|
||||
aiAuthorStore.resetImageState();
|
||||
}}
|
||||
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-interactive-hover"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
<button
|
||||
onclick={saveGeneratedImage}
|
||||
disabled={loading}
|
||||
class="rounded bg-theme-primary-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-theme-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Speichere...' : 'Zur Galerie hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
222
games/worldream/apps/web/src/lib/components/ImageGallery.svelte
Normal file
222
games/worldream/apps/web/src/lib/components/ImageGallery.svelte
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<script lang="ts">
|
||||
import type { NodeKind } from '$lib/types/content';
|
||||
|
||||
interface ImageItem {
|
||||
id: string;
|
||||
image_url: string;
|
||||
prompt?: string;
|
||||
is_primary: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
images: ImageItem[];
|
||||
nodeSlug: string;
|
||||
nodeKind: NodeKind;
|
||||
editable?: boolean;
|
||||
onImageUpdate?: () => void;
|
||||
}
|
||||
|
||||
let { images = [], nodeSlug, nodeKind, editable = false, onImageUpdate }: Props = $props();
|
||||
|
||||
let selectedImage = $state<ImageItem | null>(null);
|
||||
let showLightbox = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
// Sort images: primary first, then by sort_order
|
||||
let sortedImages = $derived(
|
||||
[...images].sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) return -1;
|
||||
if (!a.is_primary && b.is_primary) return 1;
|
||||
return a.sort_order - b.sort_order;
|
||||
})
|
||||
);
|
||||
|
||||
let primaryImage = $derived(sortedImages.find((img) => img.is_primary) || sortedImages[0]);
|
||||
let galleryImages = $derived(sortedImages.filter((img) => !img.is_primary));
|
||||
|
||||
function openLightbox(image: ImageItem) {
|
||||
selectedImage = image;
|
||||
showLightbox = true;
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
showLightbox = false;
|
||||
selectedImage = null;
|
||||
}
|
||||
|
||||
async function setPrimaryImage(imageId: string) {
|
||||
if (!editable || loading) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_primary: true }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onImageUpdate?.();
|
||||
} else {
|
||||
console.error('Failed to set primary image');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting primary image:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteImage(imageId: string) {
|
||||
if (!editable || loading) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onImageUpdate?.();
|
||||
} else {
|
||||
console.error('Failed to delete image');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get aspect ratio class based on node kind for primary image display
|
||||
function getAspectClass() {
|
||||
switch (nodeKind) {
|
||||
case 'world':
|
||||
case 'place':
|
||||
return 'w-full aspect-[21/9]'; // 21:9 ultrawide
|
||||
case 'character':
|
||||
return 'w-full aspect-[9/16]'; // Portrait 9:16 format
|
||||
case 'object':
|
||||
default:
|
||||
return 'w-full aspect-square'; // 1:1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if images.length > 0}
|
||||
<!-- Primary Image Display -->
|
||||
{#if primaryImage}
|
||||
<div class="mb-6">
|
||||
<div class="group relative">
|
||||
<button onclick={() => openLightbox(primaryImage)} class="block w-full">
|
||||
<img
|
||||
src={primaryImage.image_url}
|
||||
alt="Hauptbild"
|
||||
class={`${getAspectClass()} rounded-lg object-cover shadow-lg transition-shadow hover:shadow-xl`}
|
||||
onload={() => console.log('🖼️ Primary image loaded:', primaryImage.image_url)}
|
||||
onerror={(e) =>
|
||||
console.error('🚨 Primary image failed to load:', primaryImage.image_url, e)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
{#if galleryImages.length > 0}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{#each galleryImages as image}
|
||||
<div class="group relative">
|
||||
<button onclick={() => openLightbox(image)} class="block w-full">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt="Galeriebild"
|
||||
class="aspect-square w-full rounded-lg object-cover shadow transition-shadow hover:shadow-lg"
|
||||
onload={() => console.log('🖼️ Gallery image loaded:', image.image_url)}
|
||||
onerror={(e) => console.error('🚨 Gallery image failed to load:', image.image_url, e)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if editable}
|
||||
<div
|
||||
class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
onclick={() => setPrimaryImage(image.id)}
|
||||
disabled={loading}
|
||||
class="rounded-full bg-white p-1 shadow-md hover:bg-yellow-50 disabled:opacity-50"
|
||||
title="Als Hauptbild setzen"
|
||||
>
|
||||
<svg class="h-4 w-4 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteImage(image.id)}
|
||||
disabled={loading}
|
||||
class="hover:bg-theme-error/10 rounded-full bg-theme-surface p-1 shadow-md disabled:opacity-50"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-error"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="py-8 text-center text-gray-500">Noch keine Bilder vorhanden</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lightbox -->
|
||||
{#if showLightbox && selectedImage}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
|
||||
onclick={closeLightbox}
|
||||
>
|
||||
<div class="relative max-h-full max-w-6xl">
|
||||
<img
|
||||
src={selectedImage.image_url}
|
||||
alt="Vollbild"
|
||||
class="max-h-[90vh] max-w-full object-contain"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<button onclick={closeLightbox} class="absolute right-4 top-4 text-white hover:text-gray-300">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if selectedImage.prompt}
|
||||
<div
|
||||
class="absolute bottom-4 left-4 right-4 mx-auto max-w-2xl rounded-lg bg-black bg-opacity-75 p-4 text-white"
|
||||
>
|
||||
<p class="text-sm">{selectedImage.prompt}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { loadingStore } from '$lib/stores/loadingStore';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
nodeSlug: string;
|
||||
onClose: () => void;
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
let { show, nodeSlug, onClose, onUploadComplete }: Props = $props();
|
||||
|
||||
let dragActive = $state(false);
|
||||
let selectedFiles = $state<File[]>([]);
|
||||
let uploadProgress = $state<number>(0);
|
||||
let uploading = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
let previews = $state<{ file: File; url: string }[]>([]);
|
||||
|
||||
// Max file size: 10MB
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragActive = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set dragActive to false if we're leaving the drop zone entirely
|
||||
const target = e.target as HTMLElement;
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!target.closest('.drop-zone') || !relatedTarget?.closest('.drop-zone')) {
|
||||
dragActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragActive = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = Array.from(target.files || []);
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
function processFiles(files: File[]) {
|
||||
const validFiles = files.filter((file) => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
alert(`${file.name} ist kein unterstütztes Bildformat`);
|
||||
return false;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`${file.name} ist zu groß (max. 10MB)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
selectedFiles = [...selectedFiles, ...validFiles];
|
||||
|
||||
// Create preview URLs
|
||||
validFiles.forEach((file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
previews = [...previews, { file, url }];
|
||||
});
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
// Revoke the URL to free memory
|
||||
URL.revokeObjectURL(previews[index].url);
|
||||
|
||||
selectedFiles = selectedFiles.filter((_, i) => i !== index);
|
||||
previews = previews.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
uploading = true;
|
||||
// Create upload steps based on number of files
|
||||
const steps = selectedFiles.map(
|
||||
(file, i) => `Lade Bild ${i + 1}/${selectedFiles.length}: ${file.name}`
|
||||
);
|
||||
loadingStore.start('Bilder werden hochgeladen', steps);
|
||||
uploadProgress = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Set first image as primary if no images exist yet
|
||||
formData.append('is_primary', i === 0 ? 'true' : 'false');
|
||||
|
||||
const response = await fetch(`/api/nodes/${nodeSlug}/images/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Upload fehlgeschlagen: ${error}`);
|
||||
}
|
||||
|
||||
uploadProgress = ((i + 1) / selectedFiles.length) * 100;
|
||||
loadingStore.nextStep(`Bild ${i + 1} erfolgreich hochgeladen`);
|
||||
}
|
||||
|
||||
// Clean up preview URLs
|
||||
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
|
||||
|
||||
// Reset state
|
||||
selectedFiles = [];
|
||||
previews = [];
|
||||
uploadProgress = 0;
|
||||
|
||||
// Mark loading as complete
|
||||
loadingStore.complete('Alle Bilder erfolgreich hochgeladen');
|
||||
|
||||
// Notify parent
|
||||
onUploadComplete();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
loadingStore.setError(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
|
||||
alert(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
|
||||
// Reset loading after error
|
||||
setTimeout(() => loadingStore.reset(), 2000);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
// Clean up URLs when component is destroyed
|
||||
$effect(() => {
|
||||
return () => {
|
||||
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={onClose}
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-3xl rounded-lg bg-theme-surface p-6 shadow-xl"
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-theme-text-primary">Bilder hochladen</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone mb-6 rounded-lg border-2 border-dashed p-8 text-center transition-colors
|
||||
{dragActive
|
||||
? 'border-theme-primary-600 bg-theme-primary-100/10'
|
||||
: 'border-theme-border-subtle hover:border-theme-border-default'}"
|
||||
ondragenter={handleDragEnter}
|
||||
ondragleave={handleDragLeave}
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-4 h-12 w-12 text-theme-text-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mb-2 text-lg text-theme-text-primary">
|
||||
Bilder hier ablegen oder
|
||||
<button onclick={openFileDialog} class="text-theme-primary-600 hover:underline">
|
||||
durchsuchen
|
||||
</button>
|
||||
</p>
|
||||
<p class="text-sm text-theme-text-secondary">
|
||||
JPG, PNG, WebP oder GIF • Max. 10MB pro Bild
|
||||
</p>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Preview Grid -->
|
||||
{#if previews.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-3 text-sm font-medium text-theme-text-primary">
|
||||
Ausgewählte Bilder ({previews.length})
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||
{#each previews as preview, index}
|
||||
<div class="group relative">
|
||||
<img
|
||||
src={preview.url}
|
||||
alt="Vorschau"
|
||||
class="aspect-square w-full rounded-lg object-cover"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeFile(index)}
|
||||
class="absolute right-1 top-1 rounded-full bg-red-600 p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if index === 0}
|
||||
<span
|
||||
class="absolute bottom-1 left-1 rounded bg-yellow-500 px-1.5 py-0.5 text-xs font-semibold text-white"
|
||||
>
|
||||
Hauptbild
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Progress -->
|
||||
{#if uploading}
|
||||
<div class="mb-6">
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-theme-text-secondary">Hochladen...</span>
|
||||
<span class="text-theme-text-primary">{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-theme-elevated">
|
||||
<div
|
||||
class="h-full bg-theme-primary-600 transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
disabled={uploading}
|
||||
class="rounded-lg px-4 py-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={uploadFiles}
|
||||
disabled={selectedFiles.length === 0 || uploading}
|
||||
class="rounded-lg bg-theme-primary-600 px-4 py-2 text-white hover:bg-theme-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? 'Wird hochgeladen...'
|
||||
: `${selectedFiles.length} Bild${selectedFiles.length !== 1 ? 'er' : ''} hochladen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
141
games/worldream/apps/web/src/lib/components/ImageUploader.svelte
Normal file
141
games/worldream/apps/web/src/lib/components/ImageUploader.svelte
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<script lang="ts">
|
||||
import type { NodeKind } from '$lib/types/content';
|
||||
import AiImageGenerator from './AiImageGenerator.svelte';
|
||||
|
||||
interface Props {
|
||||
nodeSlug: string;
|
||||
nodeKind: NodeKind;
|
||||
nodeTitle: string;
|
||||
nodeDescription?: string;
|
||||
onImageAdded?: () => void;
|
||||
}
|
||||
|
||||
let { nodeSlug, nodeKind, nodeTitle, nodeDescription, onImageAdded }: Props = $props();
|
||||
|
||||
let showGenerator = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let generationPrompt = $state<string | null>(null);
|
||||
|
||||
async function handleImageGenerated(url: string) {
|
||||
imageUrl = url;
|
||||
await saveImage();
|
||||
}
|
||||
|
||||
async function saveImage() {
|
||||
if (!imageUrl) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Use the proper attachments-based endpoint
|
||||
const response = await fetch(`/api/nodes/${nodeSlug}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_url: imageUrl,
|
||||
prompt: generationPrompt || `${nodeTitle}: ${nodeDescription || ''}`,
|
||||
is_primary: false, // New images are not primary by default
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
|
||||
}
|
||||
|
||||
// Reset and close
|
||||
imageUrl = null;
|
||||
generationPrompt = null;
|
||||
showGenerator = false;
|
||||
onImageAdded?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGenerator() {
|
||||
showGenerator = !showGenerator;
|
||||
if (!showGenerator) {
|
||||
// Reset state when closing
|
||||
imageUrl = null;
|
||||
generationPrompt = null;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if !showGenerator}
|
||||
<button
|
||||
onclick={toggleGenerator}
|
||||
class="border-theme-border-default flex w-full items-center justify-center rounded-lg border-2 border-dashed px-4 py-3 transition-colors hover:border-theme-primary-400 hover:bg-theme-primary-50"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-6 w-6 text-theme-text-tertiary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="font-medium text-theme-text-secondary">Neues Bild generieren</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-theme-border-subtle bg-theme-surface p-6 shadow-sm">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-theme-text-primary">Neues Bild generieren</h3>
|
||||
<button
|
||||
onclick={toggleGenerator}
|
||||
class="text-theme-text-tertiary hover:text-theme-text-primary"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50/50 p-3">
|
||||
<p class="text-sm text-theme-error">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AiImageGenerator
|
||||
kind={nodeKind}
|
||||
title={nodeTitle}
|
||||
description={nodeDescription}
|
||||
bind:imageUrl
|
||||
bind:prompt={generationPrompt}
|
||||
onImageGenerated={handleImageGenerated}
|
||||
/>
|
||||
|
||||
{#if imageUrl}
|
||||
<div class="mt-4 flex justify-end space-x-3">
|
||||
<button
|
||||
onclick={toggleGenerator}
|
||||
class="border-theme-border-default rounded-md border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={saveImage}
|
||||
disabled={loading}
|
||||
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-theme-inverse hover:bg-theme-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Speichere...' : 'Bild zur Galerie hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
72
games/worldream/apps/web/src/lib/components/NodeCard.svelte
Normal file
72
games/worldream/apps/web/src/lib/components/NodeCard.svelte
Normal 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>
|
||||
852
games/worldream/apps/web/src/lib/components/NodeDetail.svelte
Normal file
852
games/worldream/apps/web/src/lib/components/NodeDetail.svelte
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
<script lang="ts">
|
||||
import type { ContentNode } from '$lib/types/content';
|
||||
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
|
||||
import { goto } from '$app/navigation';
|
||||
import PromptInfo from './PromptInfo.svelte';
|
||||
import ImageGallery from './ImageGallery.svelte';
|
||||
import { extractMentions } from '$lib/utils/mentions';
|
||||
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { renderMarkdown, parseReferences as parseRefs } from '$lib/utils/markdown';
|
||||
import NodeMemory from './NodeMemory.svelte';
|
||||
import SmartMarkdown from './SmartMarkdown.svelte';
|
||||
import CustomFieldsDisplay from './customFields/CustomFieldsDisplay.svelte';
|
||||
import ImageUploadModal from './ImageUploadModal.svelte';
|
||||
|
||||
interface Props {
|
||||
node: ContentNode;
|
||||
isOwner: boolean;
|
||||
onDelete: () => void;
|
||||
editPath?: string;
|
||||
backPath?: string;
|
||||
}
|
||||
|
||||
let { node: initialNode, isOwner, onDelete, editPath, backPath }: Props = $props();
|
||||
|
||||
// Make node reactive for AI updates
|
||||
let node = $state(initialNode);
|
||||
|
||||
// Update node when initialNode changes (e.g., navigation)
|
||||
$effect(() => {
|
||||
node = initialNode;
|
||||
// Update global AI Author Bar context when node changes
|
||||
if (isOwner) {
|
||||
aiAuthorStore.setContext(node, isOwner);
|
||||
}
|
||||
});
|
||||
|
||||
// Set AI Author Bar context on mount
|
||||
onMount(() => {
|
||||
console.log('🎯 NodeDetail: Setting AI context', { node: node.slug, isOwner });
|
||||
if (isOwner) {
|
||||
aiAuthorStore.setContext(node, isOwner);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for node updates from AI Author Bar
|
||||
let handleNodeUpdate: (event: CustomEvent) => void;
|
||||
let handleImagesUpdate: (event: CustomEvent) => void;
|
||||
|
||||
onMount(() => {
|
||||
handleNodeUpdate = (event: CustomEvent) => {
|
||||
if (event.detail.updatedNode.slug === node.slug) {
|
||||
node = event.detail.updatedNode;
|
||||
// Re-load linked objects if this is a character
|
||||
if (node.kind === 'character') {
|
||||
loadLinkedObjects();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleImagesUpdate = (event: CustomEvent) => {
|
||||
if (event.detail.nodeSlug === node.slug) {
|
||||
console.log('📸 Images updated event received, reloading...');
|
||||
loadImages();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('node-updated', handleNodeUpdate as EventListener);
|
||||
window.addEventListener('images-updated', handleImagesUpdate as EventListener);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (handleNodeUpdate) {
|
||||
window.removeEventListener('node-updated', handleNodeUpdate as EventListener);
|
||||
}
|
||||
if (handleImagesUpdate) {
|
||||
window.removeEventListener('images-updated', handleImagesUpdate as EventListener);
|
||||
}
|
||||
});
|
||||
|
||||
// State for linked objects
|
||||
let linkedObjects = $state<ContentNode[]>([]);
|
||||
let loadingObjects = $state(false);
|
||||
|
||||
// State for image gallery
|
||||
let images = $state<any[]>([]);
|
||||
let loadingImages = $state(false);
|
||||
|
||||
// State for tabs
|
||||
let activeTab = $state<'info' | 'memory' | 'prompt' | 'custom'>('info');
|
||||
|
||||
// State for dropdown menu
|
||||
let showDropdown = $state(false);
|
||||
|
||||
// State for left column metadata
|
||||
let showLeftMetadata = $state(false);
|
||||
|
||||
// State for image upload modal
|
||||
let showUploadModal = $state(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.dropdown-container')) {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showDropdown) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
function parseReferences(text: string | undefined): string {
|
||||
if (!text) return '';
|
||||
// Use the new markdown-aware parser
|
||||
return parseRefs(text);
|
||||
}
|
||||
|
||||
function renderContent(text: string | undefined, isStoryLore: boolean = false): string {
|
||||
if (!text) return '';
|
||||
// For story lore, always use full markdown rendering
|
||||
if (isStoryLore) {
|
||||
return renderMarkdown(text);
|
||||
}
|
||||
// For other content, use reference parser (which auto-detects markdown)
|
||||
return parseRefs(text);
|
||||
}
|
||||
|
||||
// Load objects that are in this character's inventory
|
||||
async function loadLinkedObjects() {
|
||||
if (node.kind !== 'character' || !node.content.inventory_text) return;
|
||||
|
||||
loadingObjects = true;
|
||||
try {
|
||||
const mentions = extractMentions(node.content.inventory_text);
|
||||
if (mentions.length === 0) return;
|
||||
|
||||
// Load all mentioned objects
|
||||
const objects = await Promise.all(
|
||||
mentions.map(async (slug) => {
|
||||
const response = await fetch(`/api/nodes/${slug}`);
|
||||
if (response.ok) {
|
||||
const obj = await response.json();
|
||||
if (obj.kind === 'object') return obj;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
linkedObjects = objects.filter((obj) => obj !== null) as ContentNode[];
|
||||
} catch (err) {
|
||||
console.error('Failed to load linked objects:', err);
|
||||
} finally {
|
||||
loadingObjects = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load images for the gallery
|
||||
async function loadImages() {
|
||||
console.log('📸 NodeDetail: Loading images for node:', node.slug);
|
||||
loadingImages = true;
|
||||
try {
|
||||
// Use the proper attachments-based endpoint
|
||||
const response = await fetch(`/api/nodes/${node.slug}/images`);
|
||||
console.log('📸 NodeDetail: API response status:', response.status);
|
||||
if (response.ok) {
|
||||
images = await response.json();
|
||||
console.log('📸 NodeDetail: Loaded images:', images);
|
||||
console.log('📸 NodeDetail: images.length:', images.length);
|
||||
} else {
|
||||
console.error('📸 NodeDetail: API error:', response.status, response.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('📸 NodeDetail: Failed to load images:', err);
|
||||
} finally {
|
||||
loadingImages = false;
|
||||
console.log('📸 NodeDetail: loadingImages set to false');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadLinkedObjects();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
function formatFieldName(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/text$/, '')
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
// Get the appropriate content fields based on node kind
|
||||
function getContentFields(): Array<{ key: string; label: string }> {
|
||||
const commonFields = [
|
||||
{ key: 'appearance', label: 'Aussehen' },
|
||||
{ key: 'lore', label: 'Geschichte' },
|
||||
];
|
||||
|
||||
switch (node.kind) {
|
||||
case 'world':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'canon_facts_text', label: 'Kanon-Fakten' },
|
||||
{ key: 'glossary_text', label: 'Glossar' },
|
||||
{ key: 'constraints', label: 'Einschränkungen' },
|
||||
{ key: 'timeline_text', label: 'Zeitleiste' },
|
||||
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
|
||||
];
|
||||
case 'character':
|
||||
return [
|
||||
{ key: 'state_text', label: 'Aktuelle Situation' },
|
||||
{ key: 'motivations', label: 'Motivationen' },
|
||||
...commonFields,
|
||||
{ key: 'voice_style', label: 'Sprechstil' },
|
||||
{ key: 'capabilities', label: 'Fähigkeiten' },
|
||||
{ key: 'secrets', label: 'Geheimnisse' },
|
||||
{ key: 'relationships_text', label: 'Beziehungen' },
|
||||
{ key: 'inventory_text', label: 'Inventar' },
|
||||
{ key: 'timeline_text', label: 'Zeitleiste' },
|
||||
{ key: 'constraints', label: 'Einschränkungen' },
|
||||
];
|
||||
case 'place':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'capabilities', label: 'Besonderheiten' },
|
||||
{ key: 'constraints', label: 'Gefahren' },
|
||||
{ key: 'secrets', label: 'Geheimnisse' },
|
||||
{ key: 'state_text', label: 'Aktueller Zustand' },
|
||||
{ key: 'timeline_text', label: 'Wichtige Ereignisse' },
|
||||
];
|
||||
case 'object':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'capabilities', label: 'Eigenschaften' },
|
||||
{ key: 'constraints', label: 'Einschränkungen' },
|
||||
{ key: 'secrets', label: 'Geheimnisse' },
|
||||
{ key: 'state_text', label: 'Zustand / Aufbewahrungsort' },
|
||||
];
|
||||
case 'story':
|
||||
return [
|
||||
{ key: 'lore', label: 'Story-Verlauf' },
|
||||
{ key: 'references', label: 'Referenzen' },
|
||||
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
|
||||
];
|
||||
default:
|
||||
return commonFields;
|
||||
}
|
||||
}
|
||||
|
||||
const contentFields = getContentFields();
|
||||
|
||||
// Check if layout should be side-by-side
|
||||
const isSideBySide = node.kind === 'character' || node.kind === 'object';
|
||||
</script>
|
||||
|
||||
{#if !isSideBySide && (node.kind === 'world' || node.kind === 'place') && !loadingImages && (images.length > 0 || node.image_url)}
|
||||
<!-- Fixed Full-Width Background Image for worlds and places -->
|
||||
<div class="fixed inset-0 w-full h-full" style="z-index: -1;">
|
||||
{#if images.length > 0 && images[0]?.image_url}
|
||||
<!-- Use first image from gallery as background -->
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
src={images[0].image_url}
|
||||
alt={`Bild für ${node.title}`}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else if node.image_url}
|
||||
<!-- Fallback: Direct image display when no images loaded via API -->
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
src={node.image_url}
|
||||
alt={`Bild für ${node.title}`}
|
||||
class="w-full h-full object-cover"
|
||||
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
|
||||
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mx-auto max-w-6xl relative">
|
||||
{#if isSideBySide}
|
||||
<!-- Side-by-side layout for characters and objects -->
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- Left column: Image, Title and metadata -->
|
||||
<div class="flex-shrink-0 lg:w-1/3">
|
||||
<div class="sticky top-8">
|
||||
<!-- Image -->
|
||||
{#if !loadingImages && (images.length > 0 || node.image_url)}
|
||||
{#if images.length > 0}
|
||||
<ImageGallery
|
||||
{images}
|
||||
nodeSlug={node.slug}
|
||||
nodeKind={node.kind}
|
||||
editable={isOwner}
|
||||
onImageUpdate={loadImages}
|
||||
/>
|
||||
{:else if node.image_url}
|
||||
<!-- Fallback: Direct image display when no images loaded via API -->
|
||||
<img
|
||||
src={node.image_url}
|
||||
alt={`Bild für ${node.title}`}
|
||||
class="{node.kind === 'character'
|
||||
? 'aspect-[9/16] w-full'
|
||||
: 'aspect-square w-full'} rounded-lg object-cover shadow-lg"
|
||||
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
|
||||
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Title and metadata -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<!-- Collapsible metadata button -->
|
||||
<button
|
||||
onclick={() => (showLeftMetadata = !showLeftMetadata)}
|
||||
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
|
||||
title="Metadaten anzeigen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if isOwner}
|
||||
<!-- Upload button -->
|
||||
<button
|
||||
onclick={() => (showUploadModal = true)}
|
||||
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
|
||||
title="Bilder hochladen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if node.summary}
|
||||
<p class="mt-2 text-base text-theme-text-secondary">{node.summary}</p>
|
||||
{/if}
|
||||
|
||||
{#if showLeftMetadata}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
|
||||
>
|
||||
{node.visibility}
|
||||
</span>
|
||||
{#if node.world_slug}
|
||||
<a
|
||||
href="/worlds/{node.world_slug}"
|
||||
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
|
||||
>
|
||||
🌍 {node.world_slug}
|
||||
</a>
|
||||
{/if}
|
||||
{#if node.tags && node.tags.length > 0}
|
||||
{#each node.tags as tag}
|
||||
<span
|
||||
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Content -->
|
||||
<div class="flex-1">
|
||||
<!-- Tab Navigation for all node types except stories -->
|
||||
{#if node.kind !== 'story'}
|
||||
<div
|
||||
class="sticky top-0 z-10 mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1"
|
||||
>
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
onclick={() => (activeTab = 'info')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
Informationen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'memory')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
|
||||
'memory'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
{node.kind === 'world'
|
||||
? 'Historie'
|
||||
: node.kind === 'place'
|
||||
? 'Ereignisse'
|
||||
: node.kind === 'object'
|
||||
? 'Geschichte'
|
||||
: 'Erinnerungen'}
|
||||
</button>
|
||||
{#if node.generation_prompt}
|
||||
<button
|
||||
onclick={() => (activeTab = 'prompt')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
|
||||
'prompt'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
KI-Generierung
|
||||
</button>
|
||||
{/if}
|
||||
{#if node.custom_schema && node.custom_schema.fields.length > 0}
|
||||
<button
|
||||
onclick={() => (activeTab = 'custom')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
|
||||
'custom'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
Zusatzfelder
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOwner}
|
||||
<div class="relative dropdown-container mr-1">
|
||||
<button
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
|
||||
title="Mehr Optionen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
|
||||
>
|
||||
<div class="py-1">
|
||||
{#if editPath}
|
||||
<a
|
||||
href={editPath}
|
||||
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content fields or Memory Tab or Prompt Tab -->
|
||||
<div class="rounded-lg bg-theme-surface p-6 shadow">
|
||||
{#if node.kind !== 'story' && activeTab === 'memory'}
|
||||
<!-- Memory Tab Content -->
|
||||
<NodeMemory
|
||||
nodeSlug={node.slug}
|
||||
nodeKind={node.kind}
|
||||
memory={node.memory || null}
|
||||
editable={isOwner}
|
||||
onMemoryUpdate={(updatedMemory) => {
|
||||
node.memory = updatedMemory;
|
||||
}}
|
||||
/>
|
||||
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
|
||||
<!-- Prompt Tab Content -->
|
||||
<PromptInfo {node} />
|
||||
{:else}
|
||||
<!-- Regular Content Fields -->
|
||||
<div class="space-y-6">
|
||||
{#each contentFields as field}
|
||||
{#if node.content?.[field.key]}
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
|
||||
{field.label}
|
||||
</h3>
|
||||
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
|
||||
{#if field.key === 'lore' && node.kind === 'story'}
|
||||
<SmartMarkdown
|
||||
text={node.content[field.key] || ''}
|
||||
references={node.content.references}
|
||||
/>
|
||||
{:else if field.key.includes('text') || field.key === 'references'}
|
||||
{@html parseReferences(node.content[field.key])}
|
||||
{:else}
|
||||
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Show linked objects for characters -->
|
||||
{#if node.kind === 'character' && linkedObjects.length > 0}
|
||||
<div class="border-t border-theme-border-subtle pt-6">
|
||||
<h3 class="mb-4 text-lg font-medium text-theme-text-primary">
|
||||
📒 Inventar-Objekte
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each linkedObjects as obj}
|
||||
<a
|
||||
href="/worlds/{node.world_slug}/objects/{obj.slug}"
|
||||
class="block rounded-lg bg-theme-elevated p-4 transition-colors hover:bg-theme-interactive-hover"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
{#if obj.image_url}
|
||||
<img
|
||||
src={obj.image_url}
|
||||
alt={obj.title}
|
||||
class="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-theme-text-primary">{obj.title}</h4>
|
||||
{#if obj.summary}
|
||||
<p class="mt-1 text-sm text-theme-text-secondary">{obj.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Traditional top-down layout for stories and worlds/places -->
|
||||
<div
|
||||
class="mx-auto max-w-4xl {node.kind === 'world' || node.kind === 'place'
|
||||
? 'relative z-20'
|
||||
: ''}"
|
||||
style={node.kind === 'world' || node.kind === 'place'
|
||||
? 'padding-top: 100vh; margin-top: -25vh;'
|
||||
: ''}
|
||||
>
|
||||
<!-- Regular Image for stories and other content without sticky -->
|
||||
{#if node.kind === 'story' && !loadingImages && (images.length > 0 || node.image_url)}
|
||||
<div class="mb-6">
|
||||
{#if images.length > 0}
|
||||
<ImageGallery
|
||||
{images}
|
||||
nodeSlug={node.slug}
|
||||
nodeKind={node.kind}
|
||||
editable={isOwner}
|
||||
onImageUpdate={loadImages}
|
||||
/>
|
||||
{:else if node.image_url}
|
||||
<!-- Fallback: Direct image display when no images loaded via API -->
|
||||
<div class="mb-6">
|
||||
<img
|
||||
src={node.image_url}
|
||||
alt={`Bild für ${node.title}`}
|
||||
class="aspect-square w-full rounded-lg object-cover shadow-lg"
|
||||
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
|
||||
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Title and metadata -->
|
||||
<div
|
||||
class="mb-6 {node.kind === 'world' || node.kind === 'place'
|
||||
? 'bg-theme-base/90 backdrop-blur-md rounded-lg p-6 shadow-lg'
|
||||
: ''}"
|
||||
>
|
||||
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
|
||||
{#if node.summary}
|
||||
<p class="mt-2 text-lg text-theme-text-secondary">{node.summary}</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
|
||||
>
|
||||
{node.visibility}
|
||||
</span>
|
||||
{#if node.world_slug}
|
||||
<a
|
||||
href="/worlds/{node.world_slug}"
|
||||
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
|
||||
>
|
||||
🌍 {node.world_slug}
|
||||
</a>
|
||||
{/if}
|
||||
{#if node.tags && node.tags.length > 0}
|
||||
{#each node.tags as tag}
|
||||
<span
|
||||
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation for all node types except stories -->
|
||||
{#if node.kind !== 'story'}
|
||||
<div class="mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1">
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
onclick={() => (activeTab = 'info')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
Informationen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'memory')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'memory'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
{node.kind === 'world'
|
||||
? 'Historie'
|
||||
: node.kind === 'place'
|
||||
? 'Ereignisse'
|
||||
: node.kind === 'object'
|
||||
? 'Geschichte'
|
||||
: 'Erinnerungen'}
|
||||
</button>
|
||||
{#if node.generation_prompt}
|
||||
<button
|
||||
onclick={() => (activeTab = 'prompt')}
|
||||
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
|
||||
'prompt'
|
||||
? 'bg-theme-surface text-theme-primary-600'
|
||||
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
|
||||
>
|
||||
KI-Generierung
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOwner}
|
||||
<div class="relative dropdown-container mr-1">
|
||||
<button
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
|
||||
title="Mehr Optionen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
|
||||
>
|
||||
<div class="py-1">
|
||||
{#if editPath}
|
||||
<a
|
||||
href={editPath}
|
||||
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content fields or Memory Tab or Prompt Tab -->
|
||||
<div class="rounded-lg bg-theme-surface p-6 shadow">
|
||||
{#if node.kind !== 'story' && activeTab === 'memory'}
|
||||
<!-- Memory Tab Content -->
|
||||
<NodeMemory
|
||||
nodeSlug={node.slug}
|
||||
nodeKind={node.kind}
|
||||
memory={node.memory || null}
|
||||
editable={isOwner}
|
||||
onMemoryUpdate={(updatedMemory) => {
|
||||
node.memory = updatedMemory;
|
||||
}}
|
||||
/>
|
||||
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
|
||||
<!-- Prompt Tab Content -->
|
||||
<PromptInfo {node} />
|
||||
{:else if node.kind !== 'story' && activeTab === 'custom'}
|
||||
<!-- Custom Fields Tab Content -->
|
||||
<CustomFieldsDisplay schema={node.custom_schema} data={node.custom_data} />
|
||||
{:else}
|
||||
<!-- Regular Content Fields -->
|
||||
<div class="space-y-6">
|
||||
{#each contentFields as field}
|
||||
{#if node.content?.[field.key]}
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
|
||||
{field.label}
|
||||
</h3>
|
||||
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
|
||||
{#if field.key === 'lore' && node.kind === 'story'}
|
||||
<SmartMarkdown
|
||||
text={node.content[field.key] || ''}
|
||||
references={node.content.references}
|
||||
/>
|
||||
{:else if field.key.includes('text') || field.key === 'references'}
|
||||
{@html parseReferences(node.content[field.key])}
|
||||
{:else}
|
||||
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back link and bottom padding -->
|
||||
{#if backPath}
|
||||
<div
|
||||
class="mt-6 {!isSideBySide && (node.kind === 'world' || node.kind === 'place')
|
||||
? 'pb-[100vh]'
|
||||
: 'pb-20'}"
|
||||
>
|
||||
<!-- Add massive bottom padding for world/place to show full background image -->
|
||||
<a href={backPath} class="text-theme-primary-600 hover:text-theme-primary-500">
|
||||
← Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Add bottom padding even without back link -->
|
||||
<div
|
||||
class={!isSideBySide && (node.kind === 'world' || node.kind === 'place')
|
||||
? 'pb-[100vh]'
|
||||
: 'pb-20'}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image Upload Modal -->
|
||||
{#if showUploadModal}
|
||||
<ImageUploadModal
|
||||
show={showUploadModal}
|
||||
nodeSlug={node.slug}
|
||||
onClose={() => (showUploadModal = false)}
|
||||
onUploadComplete={loadImages}
|
||||
/>
|
||||
{/if}
|
||||
389
games/worldream/apps/web/src/lib/components/NodeEditForm.svelte
Normal file
389
games/worldream/apps/web/src/lib/components/NodeEditForm.svelte
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
<script lang="ts">
|
||||
import type { ContentNode, NodeKind } from '$lib/types/content';
|
||||
import { goto } from '$app/navigation';
|
||||
import AiImageGenerator from './AiImageGenerator.svelte';
|
||||
import CollapsibleOptions from './CollapsibleOptions.svelte';
|
||||
|
||||
interface Props {
|
||||
node: ContentNode;
|
||||
onSave: (updatedNode: Partial<ContentNode>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
worldSlug?: string;
|
||||
}
|
||||
|
||||
let { node, onSave, onCancel, worldSlug }: Props = $props();
|
||||
|
||||
// Basic fields
|
||||
let title = $state(node.title);
|
||||
let slug = $state(node.slug);
|
||||
let summary = $state(node.summary || '');
|
||||
let visibility = $state(node.visibility);
|
||||
let tags = $state(node.tags.join(', '));
|
||||
let imageUrl = $state(node.image_url);
|
||||
|
||||
// Content fields based on node type
|
||||
let contentFields = $state<Record<string, any>>({});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Initialize content fields based on node kind
|
||||
$effect(() => {
|
||||
const content = node.content || {};
|
||||
|
||||
switch (node.kind) {
|
||||
case 'world':
|
||||
contentFields = {
|
||||
appearance: content.appearance || '',
|
||||
lore: content.lore || '',
|
||||
canon_facts_text: content.canon_facts_text || '',
|
||||
glossary_text: content.glossary_text || '',
|
||||
constraints: content.constraints || '',
|
||||
timeline_text: content.timeline_text || '',
|
||||
prompt_guidelines: content.prompt_guidelines || '',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'character':
|
||||
contentFields = {
|
||||
appearance: content.appearance || '',
|
||||
lore: content.lore || '',
|
||||
voice_style: content.voice_style || '',
|
||||
capabilities: content.capabilities || '',
|
||||
constraints: content.constraints || '',
|
||||
motivations: content.motivations || '',
|
||||
secrets: content.secrets || '',
|
||||
relationships_text: content.relationships_text || '',
|
||||
inventory_text: content.inventory_text || '',
|
||||
timeline_text: content.timeline_text || '',
|
||||
state_text: content.state_text || '',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'place':
|
||||
contentFields = {
|
||||
appearance: content.appearance || '',
|
||||
lore: content.lore || '',
|
||||
capabilities: content.capabilities || '',
|
||||
constraints: content.constraints || '',
|
||||
state_text: content.state_text || '',
|
||||
secrets: content.secrets || '',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
contentFields = {
|
||||
appearance: content.appearance || '',
|
||||
lore: content.lore || '',
|
||||
capabilities: content.capabilities || '',
|
||||
constraints: content.constraints || '',
|
||||
state_text: content.state_text || '',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'story':
|
||||
contentFields = {
|
||||
lore: content.lore || '',
|
||||
references: content.references || '',
|
||||
prompt_guidelines: content.prompt_guidelines || '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function generateSlug() {
|
||||
if (title && slug === node.slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[äöü]/g, (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[char] || char)
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title || !slug) {
|
||||
error = 'Bitte füllen Sie alle Pflichtfelder aus';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const updatedNode: Partial<ContentNode> = {
|
||||
title,
|
||||
slug,
|
||||
summary,
|
||||
visibility,
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
content: contentFields,
|
||||
image_url: imageUrl,
|
||||
};
|
||||
|
||||
await onSave(updatedNode);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get field configuration based on node kind
|
||||
function getFieldConfig() {
|
||||
const kindNames = {
|
||||
world: 'Welt',
|
||||
character: 'Charakter',
|
||||
place: 'Ort',
|
||||
object: 'Objekt',
|
||||
story: 'Story',
|
||||
};
|
||||
|
||||
return {
|
||||
title: kindNames[node.kind] || 'Node',
|
||||
fields: getFieldsForKind(node.kind),
|
||||
};
|
||||
}
|
||||
|
||||
function getFieldsForKind(kind: NodeKind) {
|
||||
const commonFields = [
|
||||
{ key: 'appearance', label: 'Erscheinungsbild', rows: 3 },
|
||||
{ key: 'lore', label: 'Geschichte & Bedeutung', rows: 4 },
|
||||
];
|
||||
|
||||
switch (kind) {
|
||||
case 'world':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'canon_facts_text', label: 'Kanon-Fakten', rows: 3 },
|
||||
{ key: 'glossary_text', label: 'Glossar', rows: 3 },
|
||||
{ key: 'constraints', label: 'Regeln & Einschränkungen', rows: 3 },
|
||||
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3 },
|
||||
{ key: 'prompt_guidelines', label: 'KI-Richtlinien', rows: 3, optional: true },
|
||||
];
|
||||
|
||||
case 'character':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'voice_style', label: 'Stimme & Sprache', rows: 2 },
|
||||
{ key: 'capabilities', label: 'Fähigkeiten', rows: 3 },
|
||||
{ key: 'constraints', label: 'Einschränkungen', rows: 3 },
|
||||
{ key: 'motivations', label: 'Motivationen', rows: 3 },
|
||||
{ key: 'relationships_text', label: 'Beziehungen', rows: 3, optional: true },
|
||||
{ key: 'inventory_text', label: 'Inventar', rows: 3, optional: true },
|
||||
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3, optional: true },
|
||||
{ key: 'secrets', label: 'Geheimnisse', rows: 2, optional: true },
|
||||
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
|
||||
];
|
||||
|
||||
case 'place':
|
||||
return [
|
||||
...commonFields,
|
||||
{ key: 'capabilities', label: 'Was ist hier möglich?', rows: 3 },
|
||||
{ key: 'constraints', label: 'Gefahren & Einschränkungen', rows: 3 },
|
||||
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
|
||||
{ key: 'secrets', label: 'Verborgene Aspekte', rows: 2, optional: true },
|
||||
];
|
||||
|
||||
case 'object':
|
||||
return [
|
||||
{ key: 'appearance', label: 'Aussehen & Material', rows: 3 },
|
||||
{ key: 'lore', label: 'Herkunft & Geschichte', rows: 4 },
|
||||
{ key: 'capabilities', label: 'Eigenschaften & Fähigkeiten', rows: 3 },
|
||||
{ key: 'constraints', label: 'Einschränkungen & Nachteile', rows: 3 },
|
||||
{ key: 'state_text', label: 'Aktueller Zustand & Besitzer', rows: 2, optional: true },
|
||||
];
|
||||
|
||||
case 'story':
|
||||
return [
|
||||
{ key: 'lore', label: 'Story-Verlauf / Plot', rows: 6 },
|
||||
{ key: 'references', label: 'Referenzen', rows: 3, optional: true },
|
||||
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien', rows: 3, optional: true },
|
||||
];
|
||||
|
||||
default:
|
||||
return commonFields;
|
||||
}
|
||||
}
|
||||
|
||||
const config = getFieldConfig();
|
||||
const fields = config.fields;
|
||||
const optionalFields = fields.filter((f) => f.optional);
|
||||
const requiredFields = fields.filter((f) => !f.optional);
|
||||
|
||||
let hasOptionalContent = $derived(
|
||||
optionalFields.some((field) => contentFields[field.key]?.trim())
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-theme-text-primary">{config.title} bearbeiten</h1>
|
||||
<p class="mt-1 text-sm text-theme-text-secondary">
|
||||
Bearbeite die Details für "{node.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50/50 p-4">
|
||||
<p class="text-sm text-theme-error">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
|
||||
<!-- Basic Information -->
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Grundinformationen</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-theme-text-primary">Name *</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={title}
|
||||
onblur={generateSlug}
|
||||
required
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-theme-text-primary">Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
required
|
||||
pattern="[a-z0-9\-]+"
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="summary" class="block text-sm font-medium text-theme-text-primary"
|
||||
>Zusammenfassung</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
bind:value={summary}
|
||||
rows="2"
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="visibility" class="block text-sm font-medium text-theme-text-primary"
|
||||
>Sichtbarkeit</label
|
||||
>
|
||||
<select
|
||||
id="visibility"
|
||||
bind:value={visibility}
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="shared">Geteilt</option>
|
||||
<option value="public">Öffentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-theme-text-primary"
|
||||
>Tags (kommagetrennt)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Generation -->
|
||||
{#if node.kind !== 'story'}
|
||||
<div class="border-t pt-6">
|
||||
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Bild</h2>
|
||||
<AiImageGenerator bind:imageUrl prompt={`${title}: ${contentFields.appearance}`} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content Fields -->
|
||||
<div class="border-t pt-6">
|
||||
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Details</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each requiredFields as field}
|
||||
<div>
|
||||
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
|
||||
>{field.label}</label
|
||||
>
|
||||
<textarea
|
||||
id={field.key}
|
||||
bind:value={contentFields[field.key]}
|
||||
rows={field.rows}
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional Fields -->
|
||||
{#if optionalFields.length > 0}
|
||||
<CollapsibleOptions title="Erweiterte Optionen" hasContent={hasOptionalContent}>
|
||||
{#snippet children()}
|
||||
{#each optionalFields as field}
|
||||
<div>
|
||||
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
|
||||
>{field.label}</label
|
||||
>
|
||||
<textarea
|
||||
id={field.key}
|
||||
bind:value={contentFields[field.key]}
|
||||
rows={field.rows}
|
||||
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
|
||||
></textarea>
|
||||
{#if field.key === 'inventory_text'}
|
||||
<p class="mt-1 text-xs text-theme-text-secondary">
|
||||
Verwende @objekt-slug um Objekte zu verlinken
|
||||
</p>
|
||||
{:else if field.key === 'state_text' && node.kind === 'object'}
|
||||
<p class="mt-1 text-xs text-theme-text-secondary">
|
||||
z.B. 'Im Besitz von @charakter-slug'
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</CollapsibleOptions>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="border-theme-border-default rounded-md border bg-white px-4 py-2 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-slate-50 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Speichere...' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
108
games/worldream/apps/web/src/lib/components/NodeList.svelte
Normal file
108
games/worldream/apps/web/src/lib/components/NodeList.svelte
Normal 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>
|
||||
506
games/worldream/apps/web/src/lib/components/NodeMemory.svelte
Normal file
506
games/worldream/apps/web/src/lib/components/NodeMemory.svelte
Normal 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>
|
||||
114
games/worldream/apps/web/src/lib/components/PlaceSelector.svelte
Normal file
114
games/worldream/apps/web/src/lib/components/PlaceSelector.svelte
Normal 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>
|
||||
288
games/worldream/apps/web/src/lib/components/PromptInfo.svelte
Normal file
288
games/worldream/apps/web/src/lib/components/PromptInfo.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
166
games/worldream/apps/web/src/lib/components/SmartMarkdown.svelte
Normal file
166
games/worldream/apps/web/src/lib/components/SmartMarkdown.svelte
Normal 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>
|
||||
135
games/worldream/apps/web/src/lib/components/ThemeSwitcher.svelte
Normal file
135
games/worldream/apps/web/src/lib/components/ThemeSwitcher.svelte
Normal 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>
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
CustomFieldSchema,
|
||||
CustomFieldData,
|
||||
CustomFieldDefinition,
|
||||
} from '$lib/types/customFields';
|
||||
import { getDefaultValueForType } from '$lib/types/customFields';
|
||||
|
||||
interface Props {
|
||||
schema: CustomFieldSchema;
|
||||
data?: CustomFieldData;
|
||||
readonly?: boolean;
|
||||
onChange?: (data: CustomFieldData) => void;
|
||||
onSave?: (data: CustomFieldData) => void;
|
||||
}
|
||||
|
||||
let { schema, data = {}, readonly = false, onChange, onSave }: Props = $props();
|
||||
|
||||
// Initialize form data with defaults
|
||||
let formData = $state<CustomFieldData>({ ...data });
|
||||
let isDirty = $state(false);
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
// Group fields by category
|
||||
let fieldsByCategory = $derived(() => {
|
||||
const categories = new Map<string, CustomFieldDefinition[]>();
|
||||
|
||||
// Add uncategorized fields first
|
||||
const uncategorized = schema.fields.filter((f) => !f.category);
|
||||
if (uncategorized.length > 0) {
|
||||
categories.set('_uncategorized', uncategorized);
|
||||
}
|
||||
|
||||
// Group by category
|
||||
for (const field of schema.fields) {
|
||||
if (field.category) {
|
||||
if (!categories.has(field.category)) {
|
||||
categories.set(field.category, []);
|
||||
}
|
||||
categories.get(field.category)!.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
// Initialize missing fields with defaults
|
||||
$effect(() => {
|
||||
for (const field of schema.fields) {
|
||||
if (!(field.key in formData)) {
|
||||
formData[field.key] = getDefaultValueForType(field.type, field.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Track changes
|
||||
function handleFieldChange(key: string, value: any) {
|
||||
formData = { ...formData, [key]: value };
|
||||
isDirty = true;
|
||||
errors = { ...errors, [key]: '' }; // Clear error on change
|
||||
|
||||
if (onChange) {
|
||||
onChange(formData);
|
||||
}
|
||||
|
||||
// Handle formula dependencies
|
||||
updateDependentFormulas(key);
|
||||
}
|
||||
|
||||
// Update formulas that depend on changed field
|
||||
function updateDependentFormulas(changedKey: string) {
|
||||
for (const field of schema.fields) {
|
||||
if (field.type === 'formula' && field.config.dependencies?.includes(changedKey)) {
|
||||
// TODO: Recalculate formula
|
||||
// For now, just mark as needs recalculation
|
||||
formData[field.key] = `[Recalculating...]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field
|
||||
function validateField(field: CustomFieldDefinition, value: any): string | null {
|
||||
// Required validation
|
||||
if (field.required && (value === null || value === undefined || value === '')) {
|
||||
return `${field.label} ist erforderlich`;
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
case 'range':
|
||||
if (value !== null && value !== undefined) {
|
||||
if (field.config.min !== undefined && value < field.config.min) {
|
||||
return `Mindestwert ist ${field.config.min}`;
|
||||
}
|
||||
if (field.config.max !== undefined && value > field.config.max) {
|
||||
return `Maximalwert ist ${field.config.max}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (value && field.config.maxLength && value.length > field.config.maxLength) {
|
||||
return `Maximal ${field.config.maxLength} Zeichen`;
|
||||
}
|
||||
if (value && field.config.pattern) {
|
||||
const regex = new RegExp(field.config.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return 'Ungültiges Format';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
if (Array.isArray(value)) {
|
||||
if (field.config.min_items && value.length < field.config.min_items) {
|
||||
return `Mindestens ${field.config.min_items} Elemente erforderlich`;
|
||||
}
|
||||
if (field.config.max_items && value.length > field.config.max_items) {
|
||||
return `Maximal ${field.config.max_items} Elemente erlaubt`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate all fields
|
||||
function validateAll(): boolean {
|
||||
let isValid = true;
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
for (const field of schema.fields) {
|
||||
const error = validateField(field, formData[field.key]);
|
||||
if (error) {
|
||||
newErrors[field.key] = error;
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
errors = newErrors;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Handle save
|
||||
function handleSave() {
|
||||
if (validateAll() && onSave) {
|
||||
onSave(formData);
|
||||
isDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Render field based on type
|
||||
function getFieldComponent(field: CustomFieldDefinition) {
|
||||
const value = formData[field.key];
|
||||
const error = errors[field.key];
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return renderTextField(field, value, error);
|
||||
case 'number':
|
||||
return renderNumberField(field, value, error);
|
||||
case 'range':
|
||||
return renderRangeField(field, value, error);
|
||||
case 'select':
|
||||
return renderSelectField(field, value, error);
|
||||
case 'multiselect':
|
||||
return renderMultiselectField(field, value, error);
|
||||
case 'boolean':
|
||||
return renderBooleanField(field, value, error);
|
||||
case 'date':
|
||||
return renderDateField(field, value, error);
|
||||
case 'formula':
|
||||
return renderFormulaField(field, value, error);
|
||||
case 'list':
|
||||
return renderListField(field, value, error);
|
||||
case 'json':
|
||||
return renderJsonField(field, value, error);
|
||||
case 'reference':
|
||||
return renderReferenceField(field, value, error);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Field renderers
|
||||
function renderTextField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
if (field.config.multiline) {
|
||||
return `
|
||||
<textarea
|
||||
value="${value || ''}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
placeholder="${field.config.placeholder || ''}"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
></textarea>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<input
|
||||
type="text"
|
||||
value="${value || ''}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
placeholder="${field.config.placeholder || ''}"
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNumberField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
${field.config.prefix ? `<span class="text-sm text-theme-text-secondary">${field.config.prefix}</span>` : ''}
|
||||
<input
|
||||
type="number"
|
||||
value="${value ?? field.config.default ?? ''}"
|
||||
min="${field.config.min ?? ''}"
|
||||
max="${field.config.max ?? ''}"
|
||||
step="${field.config.step ?? 1}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="flex-1 px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
/>
|
||||
${field.config.unit ? `<span class="text-sm text-theme-text-secondary">${field.config.unit}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRangeField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
return `
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>${field.config.min ?? 0}</span>
|
||||
<span class="font-medium">${value ?? field.config.default ?? 0}</span>
|
||||
<span>${field.config.max ?? 100}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value="${value ?? field.config.default ?? 0}"
|
||||
min="${field.config.min ?? 0}"
|
||||
max="${field.config.max ?? 100}"
|
||||
step="${field.config.step ?? 1}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="w-full disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSelectField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
const choices = field.config.choices || [];
|
||||
return `
|
||||
<select
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
>
|
||||
<option value="">-- Wählen --</option>
|
||||
${choices
|
||||
.map(
|
||||
(choice) => `
|
||||
<option value="${choice.value}" ${value === choice.value ? 'selected' : ''}>
|
||||
${choice.label}
|
||||
</option>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMultiselectField(
|
||||
field: CustomFieldDefinition,
|
||||
value: any,
|
||||
error: string | undefined
|
||||
) {
|
||||
const choices = field.config.choices || [];
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
|
||||
// For now, render as checkboxes
|
||||
return choices
|
||||
.map(
|
||||
(choice) => `
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="${choice.value}"
|
||||
${selectedValues.includes(choice.value) ? 'checked' : ''}
|
||||
onchange="this.dispatchEvent(new CustomEvent('multiselectchange', { detail: { value: this.value, checked: this.checked } }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm">${choice.label}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderBooleanField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
return `
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
${value ? 'checked' : ''}
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.checked }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm">Aktiviert</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDateField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
return `
|
||||
<input
|
||||
type="date"
|
||||
value="${value || ''}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFormulaField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
return `
|
||||
<div class="p-3 bg-theme-elevated rounded-md">
|
||||
<div class="text-sm text-theme-text-secondary mb-1">
|
||||
Formel: ${field.config.formula}
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
${value ?? 'Wird berechnet...'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderListField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
return `
|
||||
<div class="space-y-2">
|
||||
${items
|
||||
.map(
|
||||
(item, i) => `
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="${field.config.item_type === 'number' ? 'number' : 'text'}"
|
||||
value="${item}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('listitemchange', { detail: { index: ${i}, value: this.value } }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface disabled:opacity-50"
|
||||
/>
|
||||
${
|
||||
!readonly
|
||||
? `
|
||||
<button
|
||||
onclick="this.dispatchEvent(new CustomEvent('listitemremove', { detail: ${i} }))"
|
||||
class="text-theme-error hover:text-theme-error/80"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
${
|
||||
!readonly && (!field.config.max_items || items.length < field.config.max_items)
|
||||
? `
|
||||
<button
|
||||
onclick="this.dispatchEvent(new CustomEvent('listitemadd'))"
|
||||
class="px-3 py-1 border border-theme-border-default rounded-md hover:bg-theme-elevated text-sm"
|
||||
>
|
||||
+ Element hinzufügen
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJsonField(field: CustomFieldDefinition, value: any, error: string | undefined) {
|
||||
const jsonString = JSON.stringify(value, null, 2);
|
||||
return `
|
||||
<textarea
|
||||
value="${jsonString}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: JSON.parse(this.value) }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
rows="5"
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface font-mono text-sm disabled:opacity-50"
|
||||
></textarea>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReferenceField(
|
||||
field: CustomFieldDefinition,
|
||||
value: any,
|
||||
error: string | undefined
|
||||
) {
|
||||
// For now, just render as text input
|
||||
// In production, this would be a node selector
|
||||
return `
|
||||
<input
|
||||
type="text"
|
||||
value="${value || ''}"
|
||||
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
|
||||
${readonly ? 'disabled' : ''}
|
||||
placeholder="Node-Slug eingeben"
|
||||
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
|
||||
rounded-md bg-theme-surface disabled:opacity-50"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="custom-data-form space-y-6">
|
||||
{#each fieldsByCategory() as [category, fields]}
|
||||
<div class="category-group">
|
||||
{#if category !== '_uncategorized'}
|
||||
<h3 class="text-lg font-medium mb-3 text-theme-text-primary">
|
||||
{category}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each fields as field}
|
||||
<div class="field-wrapper">
|
||||
<label class="block text-sm font-medium mb-1 text-theme-text-primary">
|
||||
{field.label}
|
||||
{#if field.required}
|
||||
<span class="text-theme-error">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if field.description}
|
||||
<p class="text-xs text-theme-text-secondary mb-2">
|
||||
{field.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Field Component -->
|
||||
<div
|
||||
class="field-component"
|
||||
onfieldchange={(e: CustomEvent) => handleFieldChange(field.key, e.detail)}
|
||||
onmultiselectchange={(e: CustomEvent) => {
|
||||
const current = formData[field.key] || [];
|
||||
if (e.detail.checked) {
|
||||
handleFieldChange(field.key, [...current, e.detail.value]);
|
||||
} else {
|
||||
handleFieldChange(
|
||||
field.key,
|
||||
current.filter((v) => v !== e.detail.value)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onlistitemchange={(e: CustomEvent) => {
|
||||
const items = [...(formData[field.key] || [])];
|
||||
items[e.detail.index] = e.detail.value;
|
||||
handleFieldChange(field.key, items);
|
||||
}}
|
||||
onlistitemremove={(e: CustomEvent) => {
|
||||
const items = [...(formData[field.key] || [])];
|
||||
items.splice(e.detail, 1);
|
||||
handleFieldChange(field.key, items);
|
||||
}}
|
||||
onlistitemadd={() => {
|
||||
const items = [...(formData[field.key] || [])];
|
||||
items.push(getDefaultValueForType(field.config.item_type || 'text'));
|
||||
handleFieldChange(field.key, items);
|
||||
}}
|
||||
>
|
||||
{@html getFieldComponent(field)}
|
||||
</div>
|
||||
|
||||
{#if errors[field.key]}
|
||||
<p class="text-sm text-theme-error mt-1">
|
||||
{errors[field.key]}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if onSave && !readonly}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!isDirty}
|
||||
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field-component :global(input),
|
||||
.field-component :global(select),
|
||||
.field-component :global(textarea) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
CustomFieldSchema,
|
||||
CustomFieldData,
|
||||
CustomFieldDefinition,
|
||||
} from '$lib/types/customFields';
|
||||
import { parseReferences } from '$lib/utils/markdown';
|
||||
|
||||
interface Props {
|
||||
schema?: CustomFieldSchema;
|
||||
data?: CustomFieldData;
|
||||
}
|
||||
|
||||
let { schema, data = {} }: Props = $props();
|
||||
|
||||
// Group fields by category
|
||||
let fieldsByCategory = $derived(() => {
|
||||
if (!schema) return new Map();
|
||||
|
||||
const categories = new Map<string, CustomFieldDefinition[]>();
|
||||
|
||||
// Add uncategorized fields first
|
||||
const uncategorized = schema.fields.filter((f) => !f.category);
|
||||
if (uncategorized.length > 0) {
|
||||
categories.set('_uncategorized', uncategorized);
|
||||
}
|
||||
|
||||
// Group by category
|
||||
for (const field of schema.fields) {
|
||||
if (field.category) {
|
||||
if (!categories.has(field.category)) {
|
||||
categories.set(field.category, []);
|
||||
}
|
||||
categories.get(field.category)!.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
// Format value for display
|
||||
function formatValue(field: CustomFieldDefinition, value: any): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return value ? '✓ Ja' : '✗ Nein';
|
||||
|
||||
case 'number':
|
||||
case 'range':
|
||||
const formatted = typeof value === 'number' ? value.toString() : value;
|
||||
return field.config.unit ? `${formatted} ${field.config.unit}` : formatted;
|
||||
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString('de-DE');
|
||||
|
||||
case 'select':
|
||||
const choice = field.config.choices?.find((c) => c.value === value);
|
||||
return choice?.label || value;
|
||||
|
||||
case 'multiselect':
|
||||
if (Array.isArray(value)) {
|
||||
const labels = value.map((v) => {
|
||||
const choice = field.config.choices?.find((c) => c.value === v);
|
||||
return choice?.label || v;
|
||||
});
|
||||
return labels.join(', ');
|
||||
}
|
||||
return value;
|
||||
|
||||
case 'list':
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
return value;
|
||||
|
||||
case 'json':
|
||||
return JSON.stringify(value, null, 2);
|
||||
|
||||
case 'formula':
|
||||
// Formulas might return complex results
|
||||
return typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
|
||||
case 'reference':
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => `@${v}`).join(', ');
|
||||
}
|
||||
return value ? `@${value}` : '—';
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value is empty
|
||||
function isEmpty(value: any): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && Object.keys(value).length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we have any non-empty values
|
||||
let hasData = $derived(() => {
|
||||
if (!schema || !data) return false;
|
||||
return schema.fields.some((field) => !isEmpty(data[field.key]));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if schema && schema.fields.length > 0}
|
||||
{#if !hasData}
|
||||
<div class="text-center py-8 text-theme-text-secondary">
|
||||
Keine benutzerdefinierten Daten vorhanden
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each fieldsByCategory() as [category, fields]}
|
||||
<div class="category-section">
|
||||
{#if category !== '_uncategorized'}
|
||||
<h3 class="text-lg font-medium mb-3 text-theme-text-primary border-b pb-2">
|
||||
{category}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each fields as field}
|
||||
{#if !isEmpty(data[field.key])}
|
||||
<div class="field-display">
|
||||
<dt class="text-sm font-medium text-theme-text-secondary mb-1">
|
||||
{field.label}
|
||||
</dt>
|
||||
<dd class="text-theme-text-primary">
|
||||
{#if field.type === 'range'}
|
||||
<!-- Special display for range fields -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-theme-elevated rounded-full h-2 relative">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-theme-primary-600 rounded-full"
|
||||
style="width: {((data[field.key] - (field.config.min ?? 0)) /
|
||||
((field.config.max ?? 100) - (field.config.min ?? 0))) *
|
||||
100}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium">
|
||||
{formatValue(field, data[field.key])}
|
||||
</span>
|
||||
</div>
|
||||
{:else if field.type === 'text' && field.config.multiline}
|
||||
<!-- Multiline text with markdown support -->
|
||||
<div class="prose prose-sm max-w-none">
|
||||
{@html parseReferences(data[field.key])}
|
||||
</div>
|
||||
{:else if field.type === 'json'}
|
||||
<!-- JSON display -->
|
||||
<pre class="text-xs bg-theme-elevated p-2 rounded overflow-x-auto">
|
||||
<code>{formatValue(field, data[field.key])}</code>
|
||||
</pre>
|
||||
{:else if field.type === 'boolean'}
|
||||
<!-- Boolean with icon -->
|
||||
<span
|
||||
class={data[field.key] ? 'text-theme-success' : 'text-theme-text-secondary'}
|
||||
>
|
||||
{formatValue(field, data[field.key])}
|
||||
</span>
|
||||
{:else if field.type === 'multiselect' || field.type === 'list'}
|
||||
<!-- Tags display for arrays -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Array.isArray(data[field.key]) ? data[field.key] : [] as item}
|
||||
<span class="inline-block px-2 py-0.5 bg-theme-elevated rounded text-sm">
|
||||
{field.type === 'multiselect'
|
||||
? field.config.choices?.find((c) => c.value === item)?.label || item
|
||||
: item}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Default display -->
|
||||
<span class="break-words">
|
||||
{@html parseReferences(formatValue(field, data[field.key]))}
|
||||
</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-theme-text-secondary">
|
||||
Keine benutzerdefinierten Felder definiert
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.field-display {
|
||||
background-color: var(--theme-background-elevated);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.field-display dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.field-display dd {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.category-section + .category-section {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--theme-border-default);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
1
games/worldream/apps/web/src/lib/index.ts
Normal file
1
games/worldream/apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
349
games/worldream/apps/web/src/lib/services/memoryService.ts
Normal file
349
games/worldream/apps/web/src/lib/services/memoryService.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
121
games/worldream/apps/web/src/lib/services/nodeService.ts
Normal file
121
games/worldream/apps/web/src/lib/services/nodeService.ts
Normal 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, '');
|
||||
}
|
||||
}
|
||||
145
games/worldream/apps/web/src/lib/services/referenceResolver.ts
Normal file
145
games/worldream/apps/web/src/lib/services/referenceResolver.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
82
games/worldream/apps/web/src/lib/storage/images.ts
Normal file
82
games/worldream/apps/web/src/lib/storage/images.ts
Normal 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;
|
||||
}
|
||||
117
games/worldream/apps/web/src/lib/stores/aiAuthorStore.ts
Normal file
117
games/worldream/apps/web/src/lib/stores/aiAuthorStore.ts
Normal 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();
|
||||
186
games/worldream/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
186
games/worldream/apps/web/src/lib/stores/authStore.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
243
games/worldream/apps/web/src/lib/stores/loadingStore.ts
Normal file
243
games/worldream/apps/web/src/lib/stores/loadingStore.ts
Normal 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();
|
||||
49
games/worldream/apps/web/src/lib/stores/worldContext.ts
Normal file
49
games/worldream/apps/web/src/lib/stores/worldContext.ts
Normal 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);
|
||||
6
games/worldream/apps/web/src/lib/supabase/client.ts
Normal file
6
games/worldream/apps/web/src/lib/supabase/client.ts
Normal 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);
|
||||
}
|
||||
18
games/worldream/apps/web/src/lib/supabase/server.ts
Normal file
18
games/worldream/apps/web/src/lib/supabase/server.ts
Normal 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: '/' });
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
120
games/worldream/apps/web/src/lib/themes/themeStore.ts
Normal file
120
games/worldream/apps/web/src/lib/themes/themeStore.ts
Normal 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();
|
||||
374
games/worldream/apps/web/src/lib/themes/themes.config.ts
Normal file
374
games/worldream/apps/web/src/lib/themes/themes.config.ts
Normal 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];
|
||||
}
|
||||
44
games/worldream/apps/web/src/lib/themes/themes.css
Normal file
44
games/worldream/apps/web/src/lib/themes/themes.css
Normal 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 */
|
||||
210
games/worldream/apps/web/src/lib/types/content.ts
Normal file
210
games/worldream/apps/web/src/lib/types/content.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
export type NodeKind = 'world' | 'character' | 'object' | 'place' | 'story';
|
||||
export type VisibilityLevel = 'private' | 'shared' | 'public';
|
||||
export type StoryEntryType = 'narration' | 'dialog' | 'note';
|
||||
|
||||
export interface GenerationContext {
|
||||
userPrompt: string;
|
||||
systemPrompt: string;
|
||||
worldContext?: string;
|
||||
selectedCharacters?: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
summary?: string;
|
||||
appearance?: string;
|
||||
voice_style?: string;
|
||||
motivations?: string;
|
||||
capabilities?: string;
|
||||
}>;
|
||||
model: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ContentNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
owner_id?: string;
|
||||
visibility: VisibilityLevel;
|
||||
tags: string[];
|
||||
world_slug?: string;
|
||||
content: ContentData;
|
||||
memory?: CharacterMemory;
|
||||
skills?: CharacterSkills;
|
||||
custom_schema?: any; // Will be CustomFieldSchema from customFields.ts
|
||||
custom_data?: Record<string, any>; // CustomFieldData
|
||||
schema_version?: number;
|
||||
generation_prompt?: string;
|
||||
generation_model?: string;
|
||||
generation_date?: string;
|
||||
generation_context?: GenerationContext;
|
||||
image_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
appearance?: string;
|
||||
image_prompt?: string;
|
||||
lore?: string;
|
||||
voice_style?: string;
|
||||
capabilities?: string;
|
||||
constraints?: string;
|
||||
motivations?: string;
|
||||
secrets?: string;
|
||||
relationships_text?: string;
|
||||
inventory_text?: string;
|
||||
timeline_text?: string;
|
||||
glossary_text?: string;
|
||||
canon_facts_text?: string;
|
||||
state_text?: string;
|
||||
prompt_guidelines?: string;
|
||||
references?: string;
|
||||
_links?: Record<string, string[]>;
|
||||
_aliases?: string[];
|
||||
_i18n?: Record<string, any>;
|
||||
// Index signature für dynamische Content-Felder
|
||||
[key: string]: string | Record<string, string[]> | string[] | Record<string, any> | undefined;
|
||||
}
|
||||
|
||||
export interface StoryEntry {
|
||||
id: string;
|
||||
story_slug: string;
|
||||
position: number;
|
||||
type: StoryEntryType;
|
||||
speaker_slug?: string;
|
||||
body: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
owner_id?: string;
|
||||
world_slug?: string;
|
||||
kind: NodeKind;
|
||||
title: string;
|
||||
prompt_template: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
usage_count: number;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PromptHistory {
|
||||
id: string;
|
||||
user_id: string;
|
||||
node_id: string;
|
||||
prompt: string;
|
||||
response?: any;
|
||||
model?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Memory System Types
|
||||
export interface ShortTermMemory {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
location?: string;
|
||||
involved?: string[];
|
||||
tags?: string[];
|
||||
importance: number;
|
||||
decay_at: string;
|
||||
}
|
||||
|
||||
export interface MediumTermMemory {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
original_details?: string;
|
||||
context?: string;
|
||||
location?: string;
|
||||
involved?: string[];
|
||||
tags?: string[];
|
||||
importance: number;
|
||||
decay_at: string;
|
||||
linked_memories?: string[];
|
||||
}
|
||||
|
||||
export interface LongTermMemory {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
emotional_weight: number;
|
||||
category: 'trauma' | 'triumph' | 'relationship' | 'skill' | 'secret' | 'manual';
|
||||
triggers?: string[];
|
||||
effects?: string;
|
||||
involved?: string[];
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryTraits {
|
||||
memory_quality: 'excellent' | 'good' | 'average' | 'poor';
|
||||
trauma_filter?: boolean;
|
||||
selective_memory?: string[];
|
||||
memory_conditions?: {
|
||||
drunk?: 'partial_blackout' | 'full_blackout' | 'fuzzy';
|
||||
stressed?: 'detail_loss' | 'time_gaps';
|
||||
happy?: 'enhanced_positive' | 'forget_negative';
|
||||
};
|
||||
}
|
||||
|
||||
export interface CharacterMemory {
|
||||
short_term_memory: ShortTermMemory[];
|
||||
medium_term_memory: MediumTermMemory[];
|
||||
long_term_memory: LongTermMemory[];
|
||||
memory_traits: MemoryTraits;
|
||||
last_processed?: string;
|
||||
}
|
||||
|
||||
// Skills System Types
|
||||
export interface Skill {
|
||||
name: string;
|
||||
level: number;
|
||||
level_text?: string;
|
||||
subskills?: Record<string, string>;
|
||||
learned_from?: string;
|
||||
learned_at?: string;
|
||||
training_years?: number;
|
||||
last_used?: string;
|
||||
conditions?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface LearningSkill {
|
||||
name: string;
|
||||
progress: number;
|
||||
teacher?: string;
|
||||
started: string;
|
||||
blocked_by?: string;
|
||||
next_milestone?: string;
|
||||
}
|
||||
|
||||
export interface SkillCondition {
|
||||
trigger: string;
|
||||
effect: string;
|
||||
}
|
||||
|
||||
export interface CharacterSkills {
|
||||
primary: Skill[];
|
||||
learning: LearningSkill[];
|
||||
conditions: Record<string, SkillCondition>;
|
||||
}
|
||||
|
||||
// Memory Event for story integration
|
||||
export interface MemoryEvent {
|
||||
id: string;
|
||||
node_id: string;
|
||||
story_id?: string;
|
||||
event_timestamp: string;
|
||||
event_type: 'observed' | 'experienced' | 'told' | 'dreamed' | 'remembered';
|
||||
raw_event: string;
|
||||
processed_memory?: any;
|
||||
memory_tier?: 'short' | 'medium' | 'long';
|
||||
importance?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
269
games/worldream/apps/web/src/lib/types/customFields.ts
Normal file
269
games/worldream/apps/web/src/lib/types/customFields.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
174
games/worldream/apps/web/src/lib/utils/logger.ts
Normal file
174
games/worldream/apps/web/src/lib/utils/logger.ts
Normal 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;
|
||||
190
games/worldream/apps/web/src/lib/utils/markdown.ts
Normal file
190
games/worldream/apps/web/src/lib/utils/markdown.ts
Normal 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>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
38
games/worldream/apps/web/src/lib/utils/mentions.ts
Normal file
38
games/worldream/apps/web/src/lib/utils/mentions.ts
Normal 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);
|
||||
}
|
||||
10
games/worldream/apps/web/src/routes/+layout.server.ts
Normal file
10
games/worldream/apps/web/src/routes/+layout.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
297
games/worldream/apps/web/src/routes/+layout.svelte
Normal file
297
games/worldream/apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
262
games/worldream/apps/web/src/routes/+page.svelte
Normal file
262
games/worldream/apps/web/src/routes/+page.svelte
Normal 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>
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
61
games/worldream/apps/web/src/routes/api/nodes/+server.ts
Normal file
61
games/worldream/apps/web/src/routes/api/nodes/+server.ts
Normal 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 });
|
||||
};
|
||||
146
games/worldream/apps/web/src/routes/api/nodes/[slug]/+server.ts
Normal file
146
games/worldream/apps/web/src/routes/api/nodes/[slug]/+server.ts
Normal 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 });
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
137
games/worldream/apps/web/src/routes/api/templates/+server.ts
Normal file
137
games/worldream/apps/web/src/routes/api/templates/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
114
games/worldream/apps/web/src/routes/auth/login/+page.svelte
Normal file
114
games/worldream/apps/web/src/routes/auth/login/+page.svelte
Normal 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>
|
||||
10
games/worldream/apps/web/src/routes/auth/logout/+server.ts
Normal file
10
games/worldream/apps/web/src/routes/auth/logout/+server.ts
Normal 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');
|
||||
};
|
||||
103
games/worldream/apps/web/src/routes/characters/+page.svelte
Normal file
103
games/worldream/apps/web/src/routes/characters/+page.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
476
games/worldream/apps/web/src/routes/database/+page.svelte
Normal file
476
games/worldream/apps/web/src/routes/database/+page.svelte
Normal 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>
|
||||
13
games/worldream/apps/web/src/routes/objects/+page.svelte
Normal file
13
games/worldream/apps/web/src/routes/objects/+page.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
13
games/worldream/apps/web/src/routes/places/+page.svelte
Normal file
13
games/worldream/apps/web/src/routes/places/+page.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
return {
|
||||
user,
|
||||
};
|
||||
};
|
||||
148
games/worldream/apps/web/src/routes/settings/+page.svelte
Normal file
148
games/worldream/apps/web/src/routes/settings/+page.svelte
Normal 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>
|
||||
13
games/worldream/apps/web/src/routes/stories/+page.svelte
Normal file
13
games/worldream/apps/web/src/routes/stories/+page.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
13
games/worldream/apps/web/src/routes/worlds/+page.svelte
Normal file
13
games/worldream/apps/web/src/routes/worlds/+page.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue