feat(context): add SvelteKit web app with Svelte 5 runes

- Add 15 routes: dashboard, spaces, documents, editor, tokens, settings, auth, etc.
- Add 10 components: DocumentCard, DocumentEditor, AIToolbar, SpaceCard, BatchCreateModal, etc.
- Add 7 Svelte 5 rune stores: documents, spaces, tokens, auth, theme, navigation, user-settings
- Add i18n with DE + EN locales
- Add types for Document, Space, AI models, token economy
- Add SvelteKit config with node adapter (port 5192)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 11:15:20 +01:00
parent 30ee708a6f
commit 2ea7bb7a18
47 changed files with 3904 additions and 0 deletions

View file

@ -0,0 +1,10 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-theme-ui/src/components";
@source "../../../../packages/shared-theme-ui/src/pages";

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,222 @@
<script lang="ts">
import { Sparkle, PaperPlaneTilt, ArrowsClockwise, CaretDown } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { tokensStore } from '$lib/stores/tokens.svelte';
import {
availableModels,
predefinedPrompts,
generateText,
getProviderForModel,
type InsertionMode,
} from '$lib/services/ai';
import { estimateCostForPrompt } from '$lib/services/tokens';
import { estimateTokens } from '$lib/utils/text';
import type { AIModelOption, TokenCostEstimate } from '$lib/types';
interface Props {
documentContent: string;
documentId?: string;
onGenerated: (text: string, mode: InsertionMode) => void;
}
let { documentContent, documentId, onGenerated }: Props = $props();
let prompt = $state('');
let selectedModel = $state<string>('gpt-4.1');
let isGenerating = $state(false);
let error = $state('');
let showPromptTemplates = $state(false);
let showModelSelector = $state(false);
let estimate = $state<TokenCostEstimate | null>(null);
let estimateTimeout: ReturnType<typeof setTimeout> | null = null;
let selectedModelLabel = $derived(
availableModels.find((m) => m.value === selectedModel)?.label || selectedModel
);
// Debounced token estimation
function updateEstimate() {
if (estimateTimeout) clearTimeout(estimateTimeout);
if (!prompt.trim()) {
estimate = null;
return;
}
estimateTimeout = setTimeout(async () => {
const fullPrompt = prompt + (documentContent ? `\n\n${documentContent}` : '');
estimate = await estimateCostForPrompt(fullPrompt, selectedModel);
}, 500);
}
$effect(() => {
prompt;
selectedModel;
updateEstimate();
});
function selectTemplate(template: (typeof predefinedPrompts)[0]) {
prompt = template.prompt + documentContent;
showPromptTemplates = false;
}
function selectModel(value: string) {
selectedModel = value;
showModelSelector = false;
}
async function generate(mode: InsertionMode) {
if (!prompt.trim() || !authStore.user?.id) return;
isGenerating = true;
error = '';
try {
const provider = getProviderForModel(selectedModel);
const result = await generateText(authStore.user.id, prompt, provider, {
model: selectedModel,
documentId,
});
tokensStore.updateBalance(result.tokenInfo.remainingTokens);
onGenerated(result.text, mode);
prompt = '';
estimate = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Generierung fehlgeschlagen';
} finally {
isGenerating = false;
}
}
</script>
<div class="ai-toolbar border-t border-border bg-card/80 backdrop-blur-sm">
<!-- Token balance -->
<div class="flex items-center justify-between px-4 py-2 border-b border-border/50 text-xs">
<div class="flex items-center gap-2 text-muted-foreground">
<Sparkle size={14} class="text-primary" />
<span
>Token-Guthaben: <strong class="text-foreground"
>{tokensStore.balance.toLocaleString()}</strong
></span
>
{#if estimate}
<span
>→ <strong class="text-amber-500"
>{Math.max(0, tokensStore.balance - estimate.appTokens).toLocaleString()}</strong
></span
>
<span class="text-muted-foreground">({estimate.appTokens} Tokens)</span>
{/if}
</div>
<a href="/tokens" class="text-primary hover:underline">Verwalten</a>
</div>
{#if error}
<div
class="px-4 py-2 text-xs text-destructive bg-destructive/10 border-b border-destructive/20"
>
{error}
</div>
{/if}
<!-- Prompt templates -->
{#if showPromptTemplates}
<div class="px-4 py-3 border-b border-border/50">
<div class="grid grid-cols-2 gap-2">
{#each predefinedPrompts as template}
<button
class="text-left p-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
onclick={() => selectTemplate(template)}
>
<div class="font-medium text-foreground">{template.title}</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Input area -->
<div class="p-3">
<div class="flex gap-2">
<div class="flex-1 relative">
<textarea
bind:value={prompt}
placeholder="Prompt eingeben oder Vorlage wählen..."
rows="2"
class="w-full px-3 py-2 text-sm rounded-lg bg-background border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
disabled={isGenerating}
></textarea>
</div>
</div>
<!-- Actions row -->
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<button
class="text-xs px-2 py-1 rounded-md text-muted-foreground hover:bg-muted transition-colors"
onclick={() => (showPromptTemplates = !showPromptTemplates)}
>
Vorlagen
</button>
<!-- Model selector -->
<div class="relative">
<button
class="text-xs px-2 py-1 rounded-md bg-muted text-foreground hover:bg-muted/80 transition-colors flex items-center gap-1"
onclick={() => (showModelSelector = !showModelSelector)}
>
{selectedModelLabel}
<CaretDown size={12} />
</button>
{#if showModelSelector}
<div
class="absolute bottom-full left-0 mb-1 bg-card border border-border rounded-lg shadow-lg z-10 min-w-[160px]"
>
{#each availableModels as model}
<button
class="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors first:rounded-t-lg last:rounded-b-lg {selectedModel ===
model.value
? 'bg-primary/10 text-primary'
: ''}"
onclick={() => selectModel(model.value)}
>
{model.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-secondary flex items-center gap-1 text-xs"
onclick={() => generate('append')}
disabled={!prompt.trim() || isGenerating}
>
{#if isGenerating}
<ArrowsClockwise size={14} class="animate-spin" />
Generiere...
{:else}
<PaperPlaneTilt size={14} />
Anhängen
{/if}
</button>
<button
class="btn btn-sm btn-primary flex items-center gap-1 text-xs"
onclick={() => generate('replace')}
disabled={!prompt.trim() || isGenerating}
>
Ersetzen
</button>
</div>
</div>
</div>
</div>
<style>
.ai-toolbar {
position: sticky;
bottom: 0;
z-index: 10;
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { Plus, Trash, FileText, Notebook, Lightning } from '@manacore/shared-icons';
import type { DocumentType } from '$lib/types';
interface BatchItem {
title: string;
type: DocumentType;
}
interface Props {
open: boolean;
loading: boolean;
onSubmit: (items: BatchItem[]) => void;
onClose: () => void;
}
let { open, loading, onSubmit, onClose }: Props = $props();
let items = $state<BatchItem[]>([{ title: '', type: 'text' }]);
function addItem() {
items = [...items, { title: '', type: 'text' }];
}
function removeItem(index: number) {
items = items.filter((_, i) => i !== index);
}
function updateTitle(index: number, title: string) {
items = items.map((item, i) => (i === index ? { ...item, title } : item));
}
function updateType(index: number, type: DocumentType) {
items = items.map((item, i) => (i === index ? { ...item, type } : item));
}
function handleSubmit(e: Event) {
e.preventDefault();
const validItems = items.filter((i) => i.title.trim());
if (validItems.length === 0) return;
onSubmit(validItems);
items = [{ title: '', type: 'text' }];
}
const typeOptions: { value: DocumentType; icon: typeof FileText; label: string }[] = [
{ value: 'text', icon: FileText, label: 'T' },
{ value: 'context', icon: Notebook, label: 'K' },
{ value: 'prompt', icon: Lightning, label: 'P' },
];
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true">
<button class="absolute inset-0 bg-black/50" onclick={onClose} aria-label="Schließen"></button>
<div
class="relative bg-card border border-border rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] overflow-y-auto"
>
<h3 class="text-lg font-semibold text-foreground mb-4">Mehrere Dokumente erstellen</h3>
<form onsubmit={handleSubmit}>
<div class="space-y-3">
{#each items as item, index}
<div class="flex items-center gap-2">
<input
type="text"
value={item.title}
oninput={(e) => updateTitle(index, (e.target as HTMLInputElement).value)}
placeholder="Dokumenttitel..."
class="flex-1 px-3 py-2 text-sm rounded-lg bg-background border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="flex gap-0.5">
{#each typeOptions as opt}
<button
type="button"
class="p-1.5 rounded text-xs transition-colors"
class:bg-primary={item.type === opt.value}
class:text-primary-foreground={item.type === opt.value}
class:text-muted-foreground={item.type !== opt.value}
class:hover:bg-muted={item.type !== opt.value}
onclick={() => updateType(index, opt.value)}
title={opt.value}
>
<opt.icon size={14} />
</button>
{/each}
</div>
{#if items.length > 1}
<button
type="button"
class="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onclick={() => removeItem(index)}
>
<Trash size={14} />
</button>
{/if}
</div>
{/each}
</div>
<button
type="button"
class="mt-3 w-full py-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center gap-1"
onclick={addItem}
>
<Plus size={14} />
Weiteres Dokument
</button>
<div class="flex justify-between items-center mt-6">
<span class="text-xs text-muted-foreground">
{items.filter((i) => i.title.trim()).length} Dokumente
</span>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary text-sm" onclick={onClose}>
Abbrechen
</button>
<button
type="submit"
class="btn btn-primary text-sm"
disabled={items.filter((i) => i.title.trim()).length === 0 || loading}
>
{loading ? 'Erstelle...' : 'Alle erstellen'}
</button>
</div>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,48 @@
<script lang="ts">
interface Props {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
open,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
destructive = false,
onConfirm,
onCancel,
}: Props = $props();
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true">
<button class="absolute inset-0 bg-black/50" onclick={onCancel} aria-label="Schließen"></button>
<div
class="relative bg-card border border-border rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl"
>
<h3 class="text-lg font-semibold text-foreground">{title}</h3>
<p class="text-sm text-muted-foreground mt-2">{message}</p>
<div class="flex justify-end gap-2 mt-6">
<button class="btn btn-secondary text-sm" onclick={onCancel}>
{cancelLabel}
</button>
<button
class="btn text-sm text-white {destructive
? 'bg-destructive hover:bg-destructive/90'
: 'btn-primary'}"
onclick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,69 @@
<script lang="ts">
interface Props {
open: boolean;
loading: boolean;
onSubmit: (name: string, description: string) => void;
onClose: () => void;
}
let { open, loading, onSubmit, onClose }: Props = $props();
let name = $state('');
let description = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) return;
onSubmit(name.trim(), description.trim());
name = '';
description = '';
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true">
<button class="absolute inset-0 bg-black/50" onclick={onClose} aria-label="Schließen"></button>
<div
class="relative bg-card border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-xl"
>
<h3 class="text-lg font-semibold text-foreground mb-4">Neuen Space erstellen</h3>
<form onsubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="space-name" class="block text-sm font-medium text-foreground mb-1">
Name
</label>
<input
id="space-name"
type="text"
bind:value={name}
placeholder="z.B. Projektnotizen"
class="w-full px-3 py-2 rounded-lg bg-background border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div>
<label for="space-desc" class="block text-sm font-medium text-foreground mb-1">
Beschreibung (optional)
</label>
<textarea
id="space-desc"
bind:value={description}
placeholder="Worum geht es in diesem Space?"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-background border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" class="btn btn-secondary text-sm" onclick={onClose}>
Abbrechen
</button>
<button type="submit" class="btn btn-primary text-sm" disabled={!name.trim() || loading}>
{loading ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,93 @@
<script lang="ts">
import type { Document } from '$lib/types';
import { formatDate, truncateText } from '$lib/utils/text';
import { PushPin, Trash, FileText, Notebook, Lightning } from '@manacore/shared-icons';
interface Props {
document: Document;
onTogglePin?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { document: doc, onTogglePin, onDelete }: Props = $props();
let showActions = $state(false);
const typeConfig = {
text: { icon: FileText, label: 'Text', color: 'text-blue-500 bg-blue-500/10' },
context: { icon: Notebook, label: 'Kontext', color: 'text-amber-500 bg-amber-500/10' },
prompt: { icon: Lightning, label: 'Prompt', color: 'text-violet-500 bg-violet-500/10' },
};
let config = $derived(typeConfig[doc.type] || typeConfig.text);
</script>
<a
href="/documents/{doc.id}"
class="block card p-4 hover:border-primary/50 transition-all group relative"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
>
<div class="flex items-start gap-3">
<div class="p-2 rounded-lg {config.color} shrink-0">
<config.icon size={20} />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-foreground truncate">{doc.title}</h3>
{#if doc.pinned}
<PushPin size={14} class="text-primary shrink-0" />
{/if}
</div>
{#if doc.content}
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
{truncateText(doc.content.replace(/^#.*\n?/, ''), 150)}
</p>
{/if}
<div class="flex items-center gap-3 mt-2">
<span class="text-xs px-1.5 py-0.5 rounded {config.color} font-medium">
{config.label}
</span>
{#if doc.metadata?.tags?.length}
{#each doc.metadata.tags.slice(0, 3) as tag}
<span class="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{tag}
</span>
{/each}
{/if}
<span class="text-xs text-muted-foreground ml-auto">{formatDate(doc.updated_at)}</span>
</div>
</div>
</div>
{#if showActions}
<div class="absolute top-2 right-2 flex gap-1">
{#if onTogglePin}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onTogglePin(doc.id);
}}
class="p-1.5 rounded-md hover:bg-muted transition-colors"
title={doc.pinned ? 'Lösen' : 'Anheften'}
>
<PushPin size={14} class={doc.pinned ? 'text-primary' : 'text-muted-foreground'} />
</button>
{/if}
{#if onDelete}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onDelete(doc.id);
}}
class="p-1.5 rounded-md hover:bg-destructive/10 transition-colors"
title="Löschen"
>
<Trash size={14} class="text-destructive" />
</button>
{/if}
</div>
{/if}
</a>

View file

@ -0,0 +1,321 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Document, DocumentType } from '$lib/types';
import { markdownToHtml } from '$lib/utils/markdown';
import { countWords } from '$lib/utils/text';
import { EDITOR_CONFIG } from '$lib/config/editor';
import {
Eye,
PencilSimple,
FileText,
Notebook,
Lightning,
FloppyDisk,
Tag,
} from '@manacore/shared-icons';
import MentionInput from './MentionInput.svelte';
import VersionNavigator from './VersionNavigator.svelte';
interface Props {
document: Document;
saving: boolean;
onSave: (updates: Partial<Document>) => void;
onTypeChange: (type: DocumentType) => void;
onTagsSave: (tags: string[]) => void;
}
let { document: doc, saving, onSave, onTypeChange, onTagsSave }: Props = $props();
let title = $state(doc.title);
let content = $state(doc.content || '');
let mode = $state<'edit' | 'preview'>('edit');
let showTags = $state(false);
let tagInput = $state('');
let tags = $state<string[]>(doc.metadata?.tags || []);
let lastSavedAt = $state<Date | null>(null);
let hasUnsavedChanges = $state(false);
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
let wordCount = $derived(countWords(content));
// Process @mentions for preview: @[Title](id) → styled link
let processedHtml = $derived(() => {
let html = markdownToHtml(content);
// Replace @[Title](uuid) with styled mention spans
html = html.replace(
/@\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="/documents/$2" class="mention-link">@$1</a>'
);
return html;
});
function scheduleAutoSave() {
hasUnsavedChanges = true;
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(() => {
save();
}, EDITOR_CONFIG.AUTO_SAVE_DELAY);
}
function save() {
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
onSave({ title, content });
lastSavedAt = new Date();
hasUnsavedChanges = false;
}
function handleTitleInput(e: Event) {
title = (e.target as HTMLInputElement).value;
scheduleAutoSave();
}
function handleContentChange(newValue: string) {
content = newValue;
scheduleAutoSave();
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
save();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
mode = mode === 'edit' ? 'preview' : 'edit';
}
}
function addTag() {
const tag = tagInput.trim();
if (tag && !tags.includes(tag)) {
tags = [...tags, tag];
tagInput = '';
onTagsSave(tags);
}
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
onTagsSave(tags);
}
function handleTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
if (hasUnsavedChanges) {
onSave({ title, content });
}
};
});
$effect(() => {
title = doc.title;
content = doc.content || '';
tags = doc.metadata?.tags || [];
});
</script>
<div class="editor-container">
<!-- Toolbar -->
<div
class="flex flex-wrap items-center justify-between gap-2 p-3 border-b border-border bg-card rounded-t-xl"
>
<div class="flex items-center gap-2">
<!-- Type selector -->
<div class="flex gap-1">
{#each [{ type: 'text' as DocumentType, icon: FileText, label: 'Text' }, { type: 'context' as DocumentType, icon: Notebook, label: 'Kontext' }, { type: 'prompt' as DocumentType, icon: Lightning, label: 'Prompt' }] as item}
<button
class="px-2 py-1 text-xs rounded-md transition-colors"
class:bg-primary={doc.type === item.type}
class:text-primary-foreground={doc.type === item.type}
class:text-muted-foreground={doc.type !== item.type}
class:hover:bg-muted={doc.type !== item.type}
onclick={() => onTypeChange(item.type)}
>
<item.icon size={14} class="inline mr-1" />
{item.label}
</button>
{/each}
</div>
<div class="w-px h-6 bg-border"></div>
<button
class="px-2 py-1 text-xs rounded-md text-muted-foreground hover:bg-muted transition-colors flex items-center gap-1"
onclick={() => (mode = mode === 'edit' ? 'preview' : 'edit')}
>
{#if mode === 'edit'}
<Eye size={14} /> Vorschau
{:else}
<PencilSimple size={14} /> Bearbeiten
{/if}
</button>
<button
class="px-2 py-1 text-xs rounded-md text-muted-foreground hover:bg-muted transition-colors flex items-center gap-1"
onclick={() => (showTags = !showTags)}
>
<Tag size={14} /> Tags
</button>
</div>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<!-- Version navigator -->
<VersionNavigator documentId={doc.id} />
<span>{wordCount} Wörter</span>
{#if saving}
<span class="text-amber-500">Speichert...</span>
{:else if hasUnsavedChanges}
<span class="text-amber-500">Ungespeichert</span>
{:else if lastSavedAt}
<span class="text-green-500">Gespeichert</span>
{/if}
<button
onclick={save}
class="p-1 rounded hover:bg-muted transition-colors"
title="Speichern (Ctrl+S)"
>
<FloppyDisk size={16} />
</button>
</div>
</div>
<!-- Tags section -->
{#if showTags}
<div class="p-3 border-b border-border bg-card/50">
<div class="flex flex-wrap items-center gap-2">
{#each tags as tag}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-muted text-foreground"
>
{tag}
<button onclick={() => removeTag(tag)} class="hover:text-destructive">×</button>
</span>
{/each}
<input
type="text"
bind:value={tagInput}
onkeydown={handleTagKeydown}
placeholder="Tag hinzufügen..."
class="text-xs bg-transparent outline-none w-32 text-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
{/if}
<!-- Title -->
<div class="p-4 pb-0">
<input
type="text"
value={title}
oninput={handleTitleInput}
placeholder="Titel..."
class="w-full text-2xl font-bold bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
/>
</div>
<!-- Content -->
<div class="p-4 flex-1">
{#if mode === 'edit'}
<MentionInput
value={content}
placeholder="Schreibe in Markdown... Tippe @ um Dokumente zu referenzieren."
onInput={handleContentChange}
/>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html processedHtml()}
</div>
{/if}
</div>
<!-- Mention hint -->
{#if mode === 'edit'}
<div class="px-4 pb-2 text-xs text-muted-foreground">
Tipp: Tippe <kbd class="px-1 py-0.5 rounded bg-muted font-mono">@</kbd> um andere Dokumente zu
referenzieren
</div>
{/if}
</div>
<style>
.editor-container {
display: flex;
flex-direction: column;
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--card));
min-height: 600px;
}
.prose :global(h1) {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.prose :global(h2) {
font-size: 1.4rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.prose :global(h3) {
font-size: 1.15rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose :global(blockquote) {
border-left: 3px solid hsl(var(--primary));
padding-left: 1rem;
margin: 1rem 0;
color: hsl(var(--muted-foreground));
}
.prose :global(code) {
background: hsl(var(--muted));
padding: 0.15rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.85em;
}
.prose :global(pre) {
background: hsl(var(--muted));
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
.prose :global(pre code) {
background: none;
padding: 0;
}
.prose :global(a) {
color: hsl(var(--primary));
text-decoration: underline;
}
.prose :global(li) {
margin-left: 1.5rem;
list-style-type: disc;
}
.prose :global(hr) {
border-color: hsl(var(--border));
margin: 1.5rem 0;
}
.prose :global(.mention-link) {
background: hsl(var(--primary) / 0.1);
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
text-decoration: none;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { FileText } from '@manacore/shared-icons';
import { documentsStore } from '$lib/stores/documents.svelte';
import type { Document } from '$lib/types';
interface Props {
value: string;
placeholder?: string;
onInput: (value: string) => void;
}
let { value, placeholder = '', onInput }: Props = $props();
let textareaEl: HTMLTextAreaElement | undefined = $state();
let showDropdown = $state(false);
let mentionQuery = $state('');
let mentionStartPos = $state(-1);
let dropdownTop = $state(0);
let dropdownLeft = $state(0);
let filteredDocs = $derived(
mentionQuery.trim()
? documentsStore.documents
.filter((d) => d.title.toLowerCase().includes(mentionQuery.toLowerCase()))
.slice(0, 6)
: []
);
function handleInput(e: Event) {
const textarea = e.target as HTMLTextAreaElement;
const newValue = textarea.value;
const cursorPos = textarea.selectionStart;
// Check if user just typed @
const textBefore = newValue.substring(0, cursorPos);
const lastAtIndex = textBefore.lastIndexOf('@');
if (lastAtIndex >= 0) {
const textAfterAt = textBefore.substring(lastAtIndex + 1);
// Only show dropdown if @ is at start or after whitespace
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
// No spaces in the query (otherwise it's not a mention)
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
mentionQuery = textAfterAt;
mentionStartPos = lastAtIndex;
showDropdown = true;
updateDropdownPosition(textarea, cursorPos);
onInput(newValue);
return;
}
}
}
showDropdown = false;
mentionQuery = '';
onInput(newValue);
}
function updateDropdownPosition(textarea: HTMLTextAreaElement, cursorPos: number) {
// Simple approximation based on character position
const lineHeight = 20;
const charWidth = 8;
const text = textarea.value.substring(0, cursorPos);
const lines = text.split('\n');
const currentLine = lines.length - 1;
const currentCol = lines[lines.length - 1].length;
const rect = textarea.getBoundingClientRect();
dropdownTop = Math.min(rect.height - 200, (currentLine + 1) * lineHeight + 8);
dropdownLeft = Math.min(rect.width - 250, currentCol * charWidth);
}
function insertMention(doc: Document) {
if (!textareaEl || mentionStartPos < 0) return;
const before = value.substring(0, mentionStartPos);
const after = value.substring(textareaEl.selectionStart);
const mention = `@[${doc.title}](${doc.id})`;
const newValue = before + mention + after;
onInput(newValue);
showDropdown = false;
mentionQuery = '';
mentionStartPos = -1;
// Refocus and set cursor
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.focus();
const newPos = before.length + mention.length;
textareaEl.setSelectionRange(newPos, newPos);
}
});
}
function handleKeydown(e: KeyboardEvent) {
if (showDropdown && e.key === 'Escape') {
e.preventDefault();
showDropdown = false;
}
}
</script>
<div class="mention-input-wrapper relative">
<textarea
bind:this={textareaEl}
{value}
oninput={handleInput}
onkeydown={handleKeydown}
{placeholder}
class="w-full h-full min-h-[400px] bg-transparent outline-none resize-none text-foreground placeholder:text-muted-foreground font-mono text-sm leading-relaxed"
></textarea>
{#if showDropdown && filteredDocs.length > 0}
<div
class="absolute bg-card border border-border rounded-lg shadow-xl z-20 w-64 max-h-48 overflow-y-auto"
style="top: {dropdownTop}px; left: {dropdownLeft}px"
>
{#each filteredDocs as doc}
<button
class="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors flex items-center gap-2 first:rounded-t-lg last:rounded-b-lg"
onmousedown={(e) => {
e.preventDefault();
insertMention(doc);
}}
>
<FileText size={14} class="text-muted-foreground shrink-0" />
<span class="truncate text-foreground">{doc.title}</span>
<span class="text-xs text-muted-foreground ml-auto shrink-0">{doc.type}</span>
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import type { Space } from '$lib/types';
import { formatDate } from '$lib/utils/text';
import { PushPin, Trash, PencilSimple, Folder } from '@manacore/shared-icons';
interface Props {
space: Space;
onTogglePin?: (id: string) => void;
onDelete?: (id: string) => void;
onEdit?: (space: Space) => void;
}
let { space, onTogglePin, onDelete, onEdit }: Props = $props();
let showActions = $state(false);
</script>
<a
href="/spaces/{space.id}"
class="block card p-4 hover:border-primary/50 transition-all group relative"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
>
<div class="flex items-start gap-3">
<div class="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<Folder size={24} />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-foreground truncate">{space.name}</h3>
{#if space.pinned}
<PushPin size={14} class="text-primary shrink-0" />
{/if}
</div>
{#if space.description}
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">{space.description}</p>
{/if}
<p class="text-xs text-muted-foreground mt-2">{formatDate(space.created_at)}</p>
</div>
</div>
{#if showActions}
<div class="absolute top-2 right-2 flex gap-1">
{#if onTogglePin}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onTogglePin(space.id);
}}
class="p-1.5 rounded-md hover:bg-muted transition-colors"
title={space.pinned ? 'Lösen' : 'Anheften'}
>
<PushPin size={14} class={space.pinned ? 'text-primary' : 'text-muted-foreground'} />
</button>
{/if}
{#if onEdit}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onEdit(space);
}}
class="p-1.5 rounded-md hover:bg-muted transition-colors"
title="Bearbeiten"
>
<PencilSimple size={14} class="text-muted-foreground" />
</button>
{/if}
{#if onDelete}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onDelete(space.id);
}}
class="p-1.5 rounded-md hover:bg-destructive/10 transition-colors"
title="Löschen"
>
<Trash size={14} class="text-destructive" />
</button>
{/if}
</div>
{/if}
</a>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CaretLeft, CaretRight, GitBranch, Clock } from '@manacore/shared-icons';
import { getDocumentVersions } from '$lib/services/documents';
import type { Document } from '$lib/types';
import { onMount } from 'svelte';
interface Props {
documentId: string;
}
let { documentId }: Props = $props();
let versions = $state<Document[]>([]);
let currentIndex = $state(-1);
let loading = $state(true);
let hasPrevious = $derived(currentIndex > 0);
let hasNext = $derived(currentIndex < versions.length - 1);
let currentVersion = $derived(versions[currentIndex]);
let isOriginal = $derived(currentIndex === 0);
onMount(() => {
loadVersions();
});
async function loadVersions() {
loading = true;
const result = await getDocumentVersions(documentId);
versions = result.data;
currentIndex = versions.findIndex((v) => v.id === documentId);
if (currentIndex === -1) currentIndex = 0;
loading = false;
}
function goToPrevious() {
if (!hasPrevious) return;
const prevDoc = versions[currentIndex - 1];
goto(`/documents/${prevDoc.id}`);
}
function goToNext() {
if (!hasNext) return;
const nextDoc = versions[currentIndex + 1];
goto(`/documents/${nextDoc.id}`);
}
// Reload when documentId changes
$effect(() => {
documentId;
loadVersions();
});
</script>
{#if !loading && versions.length > 1}
<div
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50 border border-border text-xs"
>
<GitBranch size={14} class="text-muted-foreground shrink-0" />
<button
class="p-1 rounded hover:bg-muted transition-colors disabled:opacity-30"
disabled={!hasPrevious}
onclick={goToPrevious}
title="Vorherige Version"
>
<CaretLeft size={14} />
</button>
<span class="text-muted-foreground tabular-nums">
{currentIndex + 1} / {versions.length}
</span>
<button
class="p-1 rounded hover:bg-muted transition-colors disabled:opacity-30"
disabled={!hasNext}
onclick={goToNext}
title="Nächste Version"
>
<CaretRight size={14} />
</button>
{#if currentVersion}
<span class="text-muted-foreground truncate max-w-[200px]">
{#if isOriginal}
Original
{:else if currentVersion.metadata?.generation_type}
{currentVersion.metadata.generation_type === 'summary'
? 'Zusammenfassung'
: currentVersion.metadata.generation_type === 'continuation'
? 'Fortsetzung'
: currentVersion.metadata.generation_type === 'rewrite'
? 'Umformulierung'
: currentVersion.metadata.generation_type === 'ideas'
? 'Ideen'
: 'KI-Version'}
{/if}
</span>
{#if currentVersion.metadata?.model_used}
<span class="text-muted-foreground opacity-60">({currentVersion.metadata.model_used})</span>
{/if}
{/if}
</div>
{/if}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
<div class="header-skeleton">
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
<div class="header-nav">
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
</div>
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
</div>
<div class="content-skeleton">
<SkeletonBox width="100%" height="48px" borderRadius="12px" />
<div class="cards-placeholder">
<SkeletonBox width="100%" height="120px" borderRadius="12px" />
<SkeletonBox width="100%" height="120px" borderRadius="12px" />
<SkeletonBox width="100%" height="120px" borderRadius="12px" />
</div>
</div>
</div>
<style>
.app-loading-skeleton {
min-height: 100vh;
background: hsl(var(--background));
}
.header-skeleton {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid hsl(var(--border));
}
.header-nav {
display: flex;
gap: 0.5rem;
}
.content-skeleton {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.cards-placeholder {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
}
</style>

View file

@ -0,0 +1 @@
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';

View file

@ -0,0 +1,11 @@
export const EDITOR_CONFIG = {
AUTO_SAVE_DELAY: 3000,
NEW_DOC_SAVE_DELAY: 2000,
MIN_CONTENT_LENGTH: 50,
DEBOUNCE_DELAY: 300,
SAVE_LOCK_TIMEOUT: 30000,
LOCAL_STORAGE_KEYS: {
BACKUP_PREFIX: 'doc_backup_',
DRAFT_PREFIX: 'doc_draft_',
},
};

View file

@ -0,0 +1,40 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('context_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('context_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,68 @@
{
"app": {
"name": "Context"
},
"common": {
"back": "Zurück",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen",
"create": "Erstellen",
"edit": "Bearbeiten",
"loading": "Lade...",
"search": "Suchen",
"confirm": "Bestätigen",
"close": "Schließen",
"pin": "Anheften",
"unpin": "Lösen"
},
"nav": {
"home": "Übersicht",
"spaces": "Spaces",
"documents": "Dokumente",
"settings": "Einstellungen"
},
"spaces": {
"title": "Spaces",
"create": "Neuen Space erstellen",
"empty": "Noch keine Spaces vorhanden",
"name": "Name",
"description": "Beschreibung",
"deleteConfirm": "Alle Dokumente in diesem Space werden ebenfalls gelöscht.",
"searchPlaceholder": "Spaces durchsuchen..."
},
"documents": {
"title": "Dokumente",
"create": "Neues Dokument",
"empty": "Keine Dokumente vorhanden",
"deleteConfirm": "Das Dokument wird unwiderruflich gelöscht.",
"searchPlaceholder": "Dokumente durchsuchen...",
"types": {
"all": "Alle",
"text": "Text",
"context": "Kontext",
"prompt": "Prompt"
},
"editor": {
"titlePlaceholder": "Titel...",
"contentPlaceholder": "Schreibe deinen Text in Markdown...",
"preview": "Vorschau",
"edit": "Bearbeiten",
"words": "Wörter",
"saving": "Speichert...",
"saved": "Gespeichert",
"unsaved": "Ungespeichert",
"tags": "Tags",
"addTag": "Tag hinzufügen..."
}
},
"settings": {
"title": "Einstellungen"
},
"messages": {
"saved": "Gespeichert",
"deleted": "Gelöscht",
"error": "Ein Fehler ist aufgetreten",
"created": "Erstellt"
}
}

View file

@ -0,0 +1,68 @@
{
"app": {
"name": "Context"
},
"common": {
"back": "Back",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"loading": "Loading...",
"search": "Search",
"confirm": "Confirm",
"close": "Close",
"pin": "Pin",
"unpin": "Unpin"
},
"nav": {
"home": "Overview",
"spaces": "Spaces",
"documents": "Documents",
"settings": "Settings"
},
"spaces": {
"title": "Spaces",
"create": "Create new space",
"empty": "No spaces yet",
"name": "Name",
"description": "Description",
"deleteConfirm": "All documents in this space will also be deleted.",
"searchPlaceholder": "Search spaces..."
},
"documents": {
"title": "Documents",
"create": "New document",
"empty": "No documents yet",
"deleteConfirm": "The document will be permanently deleted.",
"searchPlaceholder": "Search documents...",
"types": {
"all": "All",
"text": "Text",
"context": "Context",
"prompt": "Prompt"
},
"editor": {
"titlePlaceholder": "Title...",
"contentPlaceholder": "Write your text in Markdown...",
"preview": "Preview",
"edit": "Edit",
"words": "Words",
"saving": "Saving...",
"saved": "Saved",
"unsaved": "Unsaved",
"tags": "Tags",
"addTag": "Add tag..."
}
},
"settings": {
"title": "Settings"
},
"messages": {
"saved": "Saved",
"deleted": "Deleted",
"error": "An error occurred",
"created": "Created"
}
}

View file

@ -0,0 +1,203 @@
import type { Document, DocumentType } from '$lib/types';
import * as docsService from '$lib/services/documents';
let documents = $state<Document[]>([]);
let currentDocument = $state<Document | null>(null);
let loading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
// Filter state
let searchQuery = $state('');
let typeFilter = $state<DocumentType | 'all'>('all');
let tagFilter = $state<string[]>([]);
export const documentsStore = {
get documents() {
return documents;
},
get currentDocument() {
return currentDocument;
},
get loading() {
return loading;
},
get saving() {
return saving;
},
get error() {
return error;
},
get searchQuery() {
return searchQuery;
},
get typeFilter() {
return typeFilter;
},
get tagFilter() {
return tagFilter;
},
get filteredDocuments() {
let filtered = documents;
if (typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === typeFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (tagFilter.length > 0) {
filtered = filtered.filter((d) => tagFilter.some((tag) => d.metadata?.tags?.includes(tag)));
}
return filtered;
},
get allTags() {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
},
get stats() {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
},
setSearchQuery(query: string) {
searchQuery = query;
},
setTypeFilter(filter: DocumentType | 'all') {
typeFilter = filter;
},
setTagFilter(tags: string[]) {
tagFilter = tags;
},
async load(spaceId?: string) {
loading = true;
error = null;
try {
documents = await docsService.getDocumentsWithPreview(spaceId);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Dokumente';
} finally {
loading = false;
}
},
async loadDocument(id: string) {
loading = true;
error = null;
try {
currentDocument = await docsService.getDocumentById(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden des Dokuments';
} finally {
loading = false;
}
},
async create(
userId: string,
content: string,
type: DocumentType,
spaceId?: string,
title?: string
) {
saving = true;
try {
const result = await docsService.createDocument(
userId,
content,
type,
spaceId,
undefined,
title
);
if (result.data) {
documents = [result.data, ...documents];
currentDocument = result.data;
}
return result;
} finally {
saving = false;
}
},
async update(id: string, updates: Partial<Document>) {
saving = true;
try {
const result = await docsService.updateDocument(id, updates);
if (result.success) {
documents = documents.map((d) => (d.id === id ? { ...d, ...updates } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, ...updates };
}
}
return result;
} finally {
saving = false;
}
},
async delete(id: string) {
const result = await docsService.deleteDocument(id);
if (result.success) {
documents = documents.filter((d) => d.id !== id);
if (currentDocument?.id === id) {
currentDocument = null;
}
}
return result;
},
async togglePinned(id: string) {
const doc = documents.find((d) => d.id === id);
if (!doc) return;
const newPinned = !doc.pinned;
const result = await docsService.toggleDocumentPinned(id, newPinned);
if (result.success) {
documents = documents.map((d) => (d.id === id ? { ...d, pinned: newPinned } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, pinned: newPinned };
}
}
return result;
},
async saveTags(id: string, tags: string[]) {
const result = await docsService.saveDocumentTags(id, tags);
if (result.success) {
documents = documents.map((d) =>
d.id === id ? { ...d, metadata: { ...d.metadata, tags } } : d
);
if (currentDocument?.id === id) {
currentDocument = {
...currentDocument,
metadata: { ...currentDocument.metadata, tags },
};
}
}
return result;
},
clearCurrent() {
currentDocument = null;
},
};

View file

@ -0,0 +1,5 @@
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed } = createSimpleNavigationStores({
storageKey: 'context',
});

View file

@ -0,0 +1,72 @@
import type { Space } from '$lib/types';
import * as spacesService from '$lib/services/spaces';
let spaces = $state<Space[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const spacesStore = {
get spaces() {
return spaces;
},
get loading() {
return loading;
},
get error() {
return error;
},
get pinnedSpaces() {
return spaces.filter((s) => s.pinned);
},
async load() {
loading = true;
error = null;
try {
spaces = await spacesService.getSpaces();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Spaces';
} finally {
loading = false;
}
},
async getById(id: string): Promise<Space | null> {
return spacesService.getSpaceById(id);
},
async create(userId: string, name: string, description?: string) {
const result = await spacesService.createSpace(userId, name, description);
if (result.data) {
spaces = [result.data, ...spaces];
}
return result;
},
async update(id: string, updates: Partial<Space>) {
const result = await spacesService.updateSpace(id, updates);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, ...updates } : s));
}
return result;
},
async togglePinned(id: string) {
const space = spaces.find((s) => s.id === id);
if (!space) return;
const newPinned = !space.pinned;
const result = await spacesService.toggleSpacePinned(id, newPinned);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, pinned: newPinned } : s));
}
return result;
},
async delete(id: string) {
const result = await spacesService.deleteSpace(id);
if (result.success) {
spaces = spaces.filter((s) => s.id !== id);
}
return result;
},
};

View file

@ -0,0 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'context',
defaultVariant: 'lume',
});

View file

@ -0,0 +1,64 @@
import * as tokensService from '$lib/services/tokens';
import type { TokenUsageStats } from '$lib/services/tokens';
let balance = $state(0);
let loading = $state(false);
let stats = $state<TokenUsageStats | null>(null);
let transactions = $state<tokensService.TokenTransaction[]>([]);
let timeframe = $state<'day' | 'week' | 'month' | 'year'>('month');
export const tokensStore = {
get balance() {
return balance;
},
get loading() {
return loading;
},
get stats() {
return stats;
},
get transactions() {
return transactions;
},
get timeframe() {
return timeframe;
},
async loadBalance(userId: string) {
balance = await tokensService.getCurrentTokenBalance(userId);
},
async loadStats(userId: string) {
loading = true;
try {
stats = await tokensService.getTokenUsageStats(userId, timeframe);
} finally {
loading = false;
}
},
async loadTransactions(userId: string) {
transactions = await tokensService.getTokenTransactions(userId);
},
async loadAll(userId: string) {
loading = true;
try {
await Promise.all([
this.loadBalance(userId),
this.loadStats(userId),
this.loadTransactions(userId),
]);
} finally {
loading = false;
}
},
setTimeframe(tf: 'day' | 'week' | 'month' | 'year') {
timeframe = tf;
},
updateBalance(newBalance: number) {
balance = newBalance;
},
};

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'context',
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,87 @@
export type DocumentType = 'text' | 'context' | 'prompt';
export interface DocumentMetadata {
tags?: string[];
word_count?: number;
token_count?: number;
parent_document?: string;
version?: number;
generation_type?: 'summary' | 'continuation' | 'rewrite' | 'ideas';
model_used?: string;
prompt_used?: string;
original_title?: string;
version_history?: Array<{
id: string;
title: string;
type: string;
created_at: string;
is_original: boolean;
}>;
[key: string]: unknown;
}
export interface Document {
id: string;
title: string;
content: string | null;
type: DocumentType;
space_id: string | null;
user_id: string;
created_at: string;
updated_at: string;
metadata: DocumentMetadata | null;
short_id?: string;
pinned?: boolean;
}
export interface Space {
id: string;
name: string;
description: string | null;
user_id: string;
created_at: string;
settings: Record<string, unknown> | null;
pinned: boolean;
prefix?: string;
text_doc_counter?: number;
context_doc_counter?: number;
prompt_doc_counter?: number;
}
export type AIProvider = 'azure' | 'google';
export interface AIModelOption {
label: string;
value: string;
provider: AIProvider;
}
export interface AIGenerationOptions {
model?: string;
temperature?: number;
maxTokens?: number;
prompt?: string;
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
}
export interface AIGenerationResult {
text: string;
tokenInfo: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
tokensUsed: number;
remainingTokens: number;
};
}
export interface TokenCostEstimate {
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
appTokens: number;
basePromptTokens?: number;
documentTokens?: number;
}

View file

@ -0,0 +1,62 @@
/**
* Extracts a title from markdown content (first H1 heading)
*/
export function extractTitleFromMarkdown(content: string): string {
if (!content) return 'Neues Dokument';
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('# ')) {
return trimmed.substring(2).trim();
}
}
// Fallback: use first non-empty line truncated
const firstLine = lines.find((l) => l.trim().length > 0);
if (firstLine) {
const clean = firstLine.replace(/^#+\s*/, '').trim();
return clean.length > 60 ? clean.substring(0, 57) + '...' : clean;
}
return 'Neues Dokument';
}
/**
* Simple markdown to HTML converter for preview
*/
export function markdownToHtml(content: string): string {
if (!content) return '';
let html = content
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headers
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold & italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Blockquotes
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Unordered lists
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Line breaks
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}

View file

@ -0,0 +1,49 @@
/**
* Counts words in a text string
*/
export function countWords(text: string): number {
if (!text) return 0;
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
/**
* Estimates token count (1 token 4 characters)
*/
export function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/**
* Formats a date string for display
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/**
* Truncates text to a given length
*/
export function truncateText(text: string, maxLength: number): string {
if (!text || text.length <= maxLength) return text || '';
return text.substring(0, maxLength - 3) + '...';
}

View file

@ -0,0 +1,305 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
const appItems = getPillAppItems('context');
let { children } = $props();
let commandBarOpen = $state(false);
const commandBarQuickActions: QuickAction[] = [
{
id: 'new-space',
label: 'Neuen Space erstellen',
icon: 'folder',
href: '/spaces?new=true',
shortcut: 'S',
},
{
id: 'new-document',
label: 'Neues Dokument',
icon: 'file-text',
href: '/documents?new=true',
shortcut: 'D',
},
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const q = query.toLowerCase();
const results: CommandBarItem[] = [];
// Search spaces
spacesStore.spaces
.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((s) => {
results.push({
id: `space-${s.id}`,
title: s.name,
subtitle: s.description || 'Space',
});
});
// Search documents
documentsStore.documents
.filter((d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((d) => {
const typeLabel = d.type === 'text' ? 'Text' : d.type === 'context' ? 'Kontext' : 'Prompt';
results.push({
id: `doc-${d.id}`,
title: d.title,
subtitle: typeLabel,
});
});
return results.slice(0, 10);
}
function handleCommandBarSelect(item: CommandBarItem) {
if (item.id.startsWith('space-')) {
goto(`/spaces/${item.id.replace('space-', '')}`);
} else if (item.id.startsWith('doc-')) {
goto(`/documents/${item.id.replace('doc-', '')}`);
}
}
let isCollapsed = $state(false);
let isDark = $derived(theme.isDark);
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
let themeVariantItems = $derived<PillDropdownItem[]>([
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
let userEmail = $derived(authStore.user?.email || 'Menü');
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Übersicht', icon: 'home' },
{ href: '/spaces', label: 'Spaces', icon: 'folder' },
{ href: '/documents', label: 'Dokumente', icon: 'file-text' },
{ href: '/tokens', label: 'Tokens', icon: 'sparkle' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
const navItems = $derived(
filterHiddenNavItems('context', baseNavItems, userSettings.nav?.hiddenNavItems || {})
);
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('context-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
const savedCollapsed = localStorage.getItem('context-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
await userSettings.load();
// Pre-load data for CommandBar search
await Promise.all([spacesStore.load(), documentsStore.load()]);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Context"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#0ea5e9"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
position: relative;
z-index: 0;
padding-bottom: 100px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Folder, FileText, Sparkle, Plus } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let isLoading = $state(true);
let recentDocs = $state<typeof documentsStore.documents>([]);
onMount(async () => {
await spacesStore.load();
await documentsStore.load();
recentDocs = documentsStore.documents.slice(0, 6);
isLoading = false;
});
function handleDeleteDoc(id: string) {
documentsStore.delete(id);
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
}
</script>
<svelte:head>
<title>Context - Dashboard</title>
</svelte:head>
{#if isLoading}
<AppLoadingSkeleton />
{:else}
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Context</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Wissensmanagement Hub</p>
</header>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{spacesStore.spaces.length}</div>
<div class="text-xs text-muted-foreground mt-1">Spaces</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{documentsStore.stats.total}</div>
<div class="text-xs text-muted-foreground mt-1">Dokumente</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.totalWords.toLocaleString()}
</div>
<div class="text-xs text-muted-foreground mt-1">Wörter</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.text}/{documentsStore.stats.context}/{documentsStore.stats.prompt}
</div>
<div class="text-xs text-muted-foreground mt-1">Text/Kontext/Prompt</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-3 mb-8">
<a href="/spaces" class="btn btn-primary flex items-center gap-2">
<Folder size={16} />
Spaces
</a>
<a href="/documents" class="btn btn-secondary flex items-center gap-2">
<FileText size={16} />
Alle Dokumente
</a>
</div>
<!-- Pinned Spaces -->
{#if spacesStore.pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="text-lg font-semibold text-foreground mb-4">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each spacesStore.pinnedSpaces as space}
<SpaceCard {space} />
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Zuletzt bearbeitet</h2>
<a href="/documents" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each recentDocs as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
</section>
{:else}
<div class="card p-8 text-center">
<div class="p-4 rounded-full bg-muted inline-block mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch keine Dokumente</h3>
<p class="text-sm text-muted-foreground mb-4">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a href="/spaces" class="btn btn-primary inline-flex items-center gap-2">
<Plus size={16} />
Ersten Space erstellen
</a>
</div>
{/if}
</div>
{/if}
<style>
.dashboard {
max-width: 1000px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,187 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass, FileText } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { DocumentType } from '$lib/types';
let deleteTarget = $state<string | null>(null);
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
{ value: 'context', label: 'Kontext' },
{ value: 'prompt', label: 'Prompt' },
];
onMount(async () => {
await documentsStore.load();
});
async function handleCreateDocument() {
if (!authStore.user?.id) return;
const result = await documentsStore.create(
authStore.user.id,
'# Neues Dokument\n\n',
'text',
undefined,
'Neues Dokument'
);
if (result.data) {
goto(`/documents/${result.data.id}`);
}
}
function handleDeleteClick(id: string) {
deleteTarget = id;
}
async function handleDeleteConfirm() {
if (deleteTarget) {
await documentsStore.delete(deleteTarget);
deleteTarget = null;
}
}
function handleTogglePin(id: string) {
documentsStore.togglePinned(id);
}
</script>
<svelte:head>
<title>{$_('documents.title')} | Context</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('documents.title')}</h1>
<p class="text-sm text-muted-foreground mt-1">
{documentsStore.stats.total} Dokumente, {documentsStore.stats.totalWords.toLocaleString()} Wörter
</p>
</div>
<button class="btn btn-primary flex items-center gap-2" onclick={handleCreateDocument}>
<Plus size={16} />
{$_('documents.create')}
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-4 mb-6">
<div class="flex gap-2">
{#each typeFilters as filter}
<button
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
class:bg-primary={documentsStore.typeFilter === filter.value}
class:text-primary-foreground={documentsStore.typeFilter === filter.value}
class:text-muted-foreground={documentsStore.typeFilter !== filter.value}
class:hover:bg-muted={documentsStore.typeFilter !== filter.value}
onclick={() => documentsStore.setTypeFilter(filter.value)}
>
{filter.label}
{#if filter.value === 'all'}
<span class="ml-1 opacity-60">{documentsStore.stats.total}</span>
{:else if filter.value === 'text'}
<span class="ml-1 opacity-60">{documentsStore.stats.text}</span>
{:else if filter.value === 'context'}
<span class="ml-1 opacity-60">{documentsStore.stats.context}</span>
{:else if filter.value === 'prompt'}
<span class="ml-1 opacity-60">{documentsStore.stats.prompt}</span>
{/if}
</button>
{/each}
</div>
<div class="relative flex-1 max-w-xs ml-auto">
<MagnifyingGlass
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
value={documentsStore.searchQuery}
oninput={(e) => documentsStore.setSearchQuery((e.target as HTMLInputElement).value)}
placeholder="Dokumente durchsuchen..."
class="w-full pl-8 pr-3 py-2 text-sm rounded-lg bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<!-- Tags filter -->
{#if documentsStore.allTags.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each documentsStore.allTags as tag}
<button
class="text-xs px-2 py-1 rounded-full transition-colors"
class:bg-primary={documentsStore.tagFilter.includes(tag)}
class:text-primary-foreground={documentsStore.tagFilter.includes(tag)}
class:bg-muted={!documentsStore.tagFilter.includes(tag)}
class:text-muted-foreground={!documentsStore.tagFilter.includes(tag)}
onclick={() => {
const current = documentsStore.tagFilter;
if (current.includes(tag)) {
documentsStore.setTagFilter(current.filter((t) => t !== tag));
} else {
documentsStore.setTagFilter([...current, tag]);
}
}}
>
{tag}
</button>
{/each}
</div>
{/if}
<!-- Document list -->
{#if documentsStore.loading}
<div class="text-center py-12 text-muted-foreground">Lade Dokumente...</div>
{:else if documentsStore.filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
<DocumentCard document={doc} onTogglePin={handleTogglePin} onDelete={handleDeleteClick} />
{/each}
</div>
{:else if documentsStore.searchQuery || documentsStore.typeFilter !== 'all' || documentsStore.tagFilter.length > 0}
<div class="text-center py-12">
<p class="text-muted-foreground">Keine Dokumente gefunden</p>
<button
class="text-sm text-primary hover:underline mt-2"
onclick={() => {
documentsStore.setSearchQuery('');
documentsStore.setTypeFilter('all');
documentsStore.setTagFilter([]);
}}
>
Filter zurücksetzen
</button>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="p-4 rounded-full bg-muted mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h2 class="text-lg font-medium text-foreground mb-2">{$_('documents.empty')}</h2>
<p class="text-sm text-muted-foreground max-w-md mb-4">
Dokumente enthalten dein Wissen, Kontext-Referenzen und AI-Prompts.
</p>
<button class="btn btn-primary flex items-center gap-2" onclick={handleCreateDocument}>
<Plus size={16} />
{$_('documents.create')}
</button>
</div>
{/if}
</div>
<ConfirmDialog
open={deleteTarget !== null}
title="Dokument löschen?"
message="Das Dokument wird unwiderruflich gelöscht."
confirmLabel="Löschen"
destructive
onConfirm={handleDeleteConfirm}
onCancel={() => (deleteTarget = null)}
/>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ArrowLeft, Trash, Sparkle } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import { tokensStore } from '$lib/stores/tokens.svelte';
import DocumentEditor from '$lib/components/DocumentEditor.svelte';
import AIToolbar from '$lib/components/AIToolbar.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { Document, DocumentType } from '$lib/types';
import type { InsertionMode } from '$lib/services/ai';
let loading = $state(true);
let showDeleteConfirm = $state(false);
let showAI = $state(false);
let docId = $derived($page.params.id || '');
let doc = $derived(documentsStore.currentDocument);
onMount(() => {
const init = async () => {
await documentsStore.loadDocument(docId);
if (!documentsStore.currentDocument) {
goto('/documents');
return;
}
loading = false;
// Load token balance
if (authStore.user?.id) {
tokensStore.loadBalance(authStore.user.id);
}
};
init();
return () => {
documentsStore.clearCurrent();
};
});
function handleSave(updates: Partial<Document>) {
documentsStore.update(docId, updates);
}
function handleTypeChange(type: DocumentType) {
documentsStore.update(docId, { type });
}
function handleTagsSave(tags: string[]) {
documentsStore.saveTags(docId, tags);
}
function handleAIGenerated(text: string, mode: InsertionMode) {
if (!doc) return;
if (mode === 'append') {
const newContent = (doc.content || '') + '\n\n' + text;
documentsStore.update(docId, { content: newContent });
} else if (mode === 'prepend') {
const newContent = text + '\n\n' + (doc.content || '');
documentsStore.update(docId, { content: newContent });
} else if (mode === 'replace') {
documentsStore.update(docId, { content: text });
}
// Reload document to get updated content
documentsStore.loadDocument(docId);
}
async function handleDelete() {
await documentsStore.delete(docId);
if (doc?.space_id) {
goto(`/spaces/${doc.space_id}`);
} else {
goto('/documents');
}
}
</script>
<svelte:head>
<title>{doc?.title || 'Dokument'} | Context</title>
</svelte:head>
<div class="mx-auto max-w-4xl pb-48">
{#if loading}
<div class="text-center py-12 text-muted-foreground">Lade Dokument...</div>
{:else if doc}
<!-- Breadcrumb -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2 text-sm">
{#if doc.space_id}
<a
href="/spaces/{doc.space_id}"
class="text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<ArrowLeft size={14} />
Zurück zum Space
</a>
{:else}
<a
href="/documents"
class="text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<ArrowLeft size={14} />
Alle Dokumente
</a>
{/if}
</div>
<div class="flex items-center gap-2">
<button
class="p-2 rounded-lg transition-colors flex items-center gap-1 text-sm"
class:bg-primary={showAI}
class:text-primary-foreground={showAI}
class:text-muted-foreground={!showAI}
class:hover:bg-muted={!showAI}
onclick={() => (showAI = !showAI)}
title="AI-Assistent"
>
<Sparkle size={16} />
<span class="hidden sm:inline">AI</span>
</button>
<button
class="p-2 rounded-lg hover:bg-destructive/10 transition-colors text-muted-foreground hover:text-destructive"
onclick={() => (showDeleteConfirm = true)}
title="Dokument löschen"
>
<Trash size={18} />
</button>
</div>
</div>
<DocumentEditor
document={doc}
saving={documentsStore.saving}
onSave={handleSave}
onTypeChange={handleTypeChange}
onTagsSave={handleTagsSave}
/>
<!-- Document metadata -->
<div class="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
{#if doc.short_id}
<span>ID: {doc.short_id}</span>
{/if}
{#if doc.metadata?.token_count}
<span>{doc.metadata.token_count} Tokens</span>
{/if}
<span>
Erstellt: {new Date(doc.created_at).toLocaleDateString('de-DE')}
</span>
<span>
Aktualisiert: {new Date(doc.updated_at).toLocaleDateString('de-DE')}
</span>
</div>
<!-- AI Toolbar -->
{#if showAI}
<div class="fixed bottom-0 left-0 right-0 z-40">
<div class="mx-auto max-w-4xl">
<AIToolbar
documentContent={doc.content || ''}
documentId={doc.id}
onGenerated={handleAIGenerated}
/>
</div>
</div>
{/if}
{:else}
<div class="text-center py-12">
<p class="text-muted-foreground">Dokument nicht gefunden</p>
<a href="/documents" class="text-sm text-primary hover:underline mt-2 inline-block">
Zurück zur Übersicht
</a>
</div>
{/if}
</div>
<ConfirmDialog
open={showDeleteConfirm}
title="Dokument löschen?"
message="Das Dokument wird unwiderruflich gelöscht."
confirmLabel="Löschen"
destructive
onConfirm={handleDelete}
onCancel={() => (showDeleteConfirm = false)}
/>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { browser } from '$app/environment';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
const feedbackService = createFeedbackService({
appId: 'context',
apiUrl: getAuthUrl(),
getAuthToken: () => authStore.getValidToken(),
});
</script>
<FeedbackPage {feedbackService} appName="Context" currentUserId={authStore.user?.id} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
}
</script>
<SubscriptionPage appName="Context" onSubscribe={handleSubscribe} onBuyPackage={handleBuyPackage} />

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
let userProfile = $derived<UserProfile>({
id: authStore.user?.id || '',
email: authStore.user?.email || '',
});
</script>
<ProfilePage user={userProfile} appName="Context" />

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { GlobalSettingsSection } from '@manacore/shared-ui';
onMount(async () => {
await userSettings.load();
});
function translate(key: string): string {
return $_?.(key) ?? key;
}
</script>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-foreground">{$_('settings.title')}</h1>
<GlobalSettingsSection
{userSettings}
appId="context"
title="App-Einstellungen"
description="Diese Einstellungen werden mit allen Mana Apps synchronisiert"
t={translate}
/>
</div>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import CreateSpaceModal from '$lib/components/CreateSpaceModal.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { Space } from '$lib/types';
let searchQuery = $state('');
let showCreateModal = $state(false);
let creating = $state(false);
let deleteTarget = $state<string | null>(null);
let editTarget = $state<Space | null>(null);
let filteredSpaces = $derived(
searchQuery.trim()
? spacesStore.spaces.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: spacesStore.spaces
);
onMount(async () => {
await spacesStore.load();
});
async function handleCreate(name: string, description: string) {
if (!authStore.user?.id) return;
creating = true;
await spacesStore.create(authStore.user.id, name, description);
creating = false;
showCreateModal = false;
}
function handleTogglePin(id: string) {
spacesStore.togglePinned(id);
}
function handleDeleteClick(id: string) {
deleteTarget = id;
}
async function handleDeleteConfirm() {
if (deleteTarget) {
await spacesStore.delete(deleteTarget);
deleteTarget = null;
}
}
function handleEdit(space: Space) {
editTarget = space;
}
</script>
<svelte:head>
<title>Spaces | Context</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-foreground">{$_('spaces.title')}</h1>
<button
class="btn btn-primary flex items-center gap-2"
onclick={() => (showCreateModal = true)}
>
<Plus size={16} />
{$_('spaces.create')}
</button>
</div>
<!-- Search -->
<div class="relative mb-6">
<MagnifyingGlass
size={16}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Spaces durchsuchen..."
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{#if spacesStore.loading}
<div class="text-center py-12 text-muted-foreground">Lade Spaces...</div>
{:else if filteredSpaces.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each filteredSpaces as space}
<SpaceCard
{space}
onTogglePin={handleTogglePin}
onDelete={handleDeleteClick}
onEdit={handleEdit}
/>
{/each}
</div>
{:else if searchQuery}
<div class="text-center py-12">
<p class="text-muted-foreground">Keine Spaces gefunden für "{searchQuery}"</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="p-4 rounded-full bg-muted mb-4">
<Plus size={48} class="text-muted-foreground" />
</div>
<h2 class="text-lg font-medium text-foreground mb-2">{$_('spaces.empty')}</h2>
<p class="text-sm text-muted-foreground max-w-md mb-4">
Spaces helfen dir, dein Wissen zu organisieren. Erstelle deinen ersten Space, um loszulegen.
</p>
<button
class="btn btn-primary flex items-center gap-2"
onclick={() => (showCreateModal = true)}
>
<Plus size={16} />
{$_('spaces.create')}
</button>
</div>
{/if}
</div>
<CreateSpaceModal
open={showCreateModal}
loading={creating}
onSubmit={handleCreate}
onClose={() => (showCreateModal = false)}
/>
<ConfirmDialog
open={deleteTarget !== null}
title="Space löschen?"
message="Alle Dokumente in diesem Space werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
destructive
onConfirm={handleDeleteConfirm}
onCancel={() => (deleteTarget = null)}
/>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import {
Plus,
ArrowLeft,
PencilSimple,
Check,
X,
MagnifyingGlass,
ListPlus,
} from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import BatchCreateModal from '$lib/components/BatchCreateModal.svelte';
import type { Space, DocumentType } from '$lib/types';
let space = $state<Space | null>(null);
let loading = $state(true);
let editingName = $state(false);
let editName = $state('');
let editDescription = $state('');
let deleteTarget = $state<string | null>(null);
let showBatchCreate = $state(false);
let batchCreating = $state(false);
let spaceId = $derived($page.params.id || '');
onMount(async () => {
space = await spacesStore.getById(spaceId);
if (!space) {
goto('/spaces');
return;
}
editName = space.name;
editDescription = space.description || '';
await documentsStore.load(spaceId);
loading = false;
});
async function handleCreateDocument() {
if (!authStore.user?.id) return;
const result = await documentsStore.create(
authStore.user.id,
'# Neues Dokument\n\n',
'text',
spaceId,
'Neues Dokument'
);
if (result.data) {
goto(`/documents/${result.data.id}`);
}
}
function startEdit() {
editingName = true;
editName = space?.name || '';
editDescription = space?.description || '';
}
async function saveEdit() {
if (!space) return;
await spacesStore.update(space.id, {
name: editName,
description: editDescription || null,
});
space = { ...space, name: editName, description: editDescription || null };
editingName = false;
}
function cancelEdit() {
editingName = false;
editName = space?.name || '';
editDescription = space?.description || '';
}
function handleDeleteDoc(id: string) {
deleteTarget = id;
}
async function handleDeleteConfirm() {
if (deleteTarget) {
await documentsStore.delete(deleteTarget);
deleteTarget = null;
}
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
}
async function handleBatchCreate(items: { title: string; type: DocumentType }[]) {
if (!authStore.user?.id) return;
batchCreating = true;
for (const item of items) {
await documentsStore.create(
authStore.user.id,
`# ${item.title}\n\n`,
item.type,
spaceId,
item.title
);
}
batchCreating = false;
showBatchCreate = false;
await documentsStore.load(spaceId);
}
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
{ value: 'context', label: 'Kontext' },
{ value: 'prompt', label: 'Prompt' },
];
</script>
<svelte:head>
<title>{space?.name || 'Space'} | Context</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 mb-4 text-sm">
<a href="/spaces" class="text-muted-foreground hover:text-foreground flex items-center gap-1">
<ArrowLeft size={14} />
Spaces
</a>
<span class="text-muted-foreground">/</span>
<span class="text-foreground font-medium">{space?.name || '...'}</span>
</div>
{#if loading}
<div class="text-center py-12 text-muted-foreground">Lade...</div>
{:else if space}
<!-- Space Header -->
<div class="card p-6 mb-6">
{#if editingName}
<div class="space-y-3">
<input
type="text"
bind:value={editName}
class="w-full text-xl font-bold bg-background border border-border rounded-lg px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<textarea
bind:value={editDescription}
rows="2"
placeholder="Beschreibung..."
class="w-full bg-background border border-border rounded-lg px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none text-sm"
></textarea>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm flex items-center gap-1" onclick={saveEdit}>
<Check size={14} /> Speichern
</button>
<button class="btn btn-secondary btn-sm flex items-center gap-1" onclick={cancelEdit}>
<X size={14} /> Abbrechen
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-foreground">{space.name}</h1>
{#if space.description}
<p class="text-sm text-muted-foreground mt-1">{space.description}</p>
{/if}
<div class="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>{documentsStore.stats.total} Dokumente</span>
<span>{documentsStore.stats.totalWords.toLocaleString()} Wörter</span>
</div>
</div>
<button
class="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground"
onclick={startEdit}
title="Bearbeiten"
>
<PencilSimple size={18} />
</button>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-4 mb-4">
<div class="flex gap-2">
{#each typeFilters as filter}
<button
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
class:bg-primary={documentsStore.typeFilter === filter.value}
class:text-primary-foreground={documentsStore.typeFilter === filter.value}
class:text-muted-foreground={documentsStore.typeFilter !== filter.value}
class:hover:bg-muted={documentsStore.typeFilter !== filter.value}
onclick={() => documentsStore.setTypeFilter(filter.value)}
>
{filter.label}
</button>
{/each}
</div>
<div class="flex items-center gap-2">
<div class="relative">
<MagnifyingGlass
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
value={documentsStore.searchQuery}
oninput={(e) => documentsStore.setSearchQuery((e.target as HTMLInputElement).value)}
placeholder="Suchen..."
class="pl-8 pr-3 py-1.5 text-sm rounded-lg bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary w-48"
/>
</div>
<button
class="btn btn-secondary btn-sm flex items-center gap-1"
onclick={() => (showBatchCreate = true)}
title="Mehrere Dokumente erstellen"
>
<ListPlus size={14} />
Mehrere
</button>
<button
class="btn btn-primary btn-sm flex items-center gap-1"
onclick={handleCreateDocument}
>
<Plus size={14} />
Neues Dokument
</button>
</div>
</div>
<!-- Documents -->
{#if documentsStore.filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
{:else}
<div class="card p-12 text-center">
<p class="text-muted-foreground mb-4">Keine Dokumente in diesem Space</p>
<button
class="btn btn-primary flex items-center gap-2 mx-auto"
onclick={handleCreateDocument}
>
<Plus size={16} />
Erstes Dokument erstellen
</button>
</div>
{/if}
{/if}
</div>
<ConfirmDialog
open={deleteTarget !== null}
title="Dokument löschen?"
message="Das Dokument wird unwiderruflich gelöscht."
confirmLabel="Löschen"
destructive
onConfirm={handleDeleteConfirm}
onCancel={() => (deleteTarget = null)}
/>
<BatchCreateModal
open={showBatchCreate}
loading={batchCreating}
onSubmit={handleBatchCreate}
onClose={() => (showBatchCreate = false)}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte';
import { THEME_DEFINITIONS, THEME_VARIANTS } from '@manacore/shared-theme';
</script>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-foreground">Alle Themes</h1>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each THEME_VARIANTS as variant}
{@const def = THEME_DEFINITIONS[variant]}
<button
class="card text-left transition-all hover:border-primary/50"
class:border-primary={theme.variant === variant}
onclick={() => theme.setVariant(variant)}
>
<div class="flex items-center gap-3">
<span class="text-3xl">{def.emoji}</span>
<div>
<h3 class="font-semibold">{def.label}</h3>
</div>
</div>
{#if theme.variant === variant}
<div class="mt-3 text-sm text-primary">Aktiv</div>
{/if}
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Sparkle, ArrowUp, ArrowDown, Clock } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { tokensStore } from '$lib/stores/tokens.svelte';
let activeTimeframe = $state<'day' | 'week' | 'month' | 'year'>('month');
onMount(async () => {
if (authStore.user?.id) {
await tokensStore.loadAll(authStore.user.id);
}
});
async function changeTimeframe(tf: 'day' | 'week' | 'month' | 'year') {
activeTimeframe = tf;
tokensStore.setTimeframe(tf);
if (authStore.user?.id) {
await tokensStore.loadStats(authStore.user.id);
}
}
const timeframeLabels = {
day: 'Heute',
week: 'Woche',
month: 'Monat',
year: 'Jahr',
};
function formatAmount(amount: number): string {
return Math.abs(amount).toLocaleString();
}
function formatTransactionType(type: string): string {
const labels: Record<string, string> = {
usage: 'Nutzung',
purchase: 'Kauf',
monthly_reset: 'Monatliches Guthaben',
admin_grant: 'Admin-Gutschrift',
refund: 'Rückerstattung',
};
return labels[type] || type;
}
</script>
<svelte:head>
<title>Token-Management | Context</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-foreground">Token-Management</h1>
<!-- Balance Card -->
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-muted-foreground">Aktuelles Guthaben</p>
<div class="flex items-center gap-3 mt-1">
<Sparkle size={28} class="text-primary" />
<span class="text-4xl font-bold text-foreground tabular-nums">
{tokensStore.balance.toLocaleString()}
</span>
</div>
<p class="text-xs text-muted-foreground mt-1">Tokens</p>
</div>
<a href="/mana" class="btn btn-primary">Tokens kaufen</a>
</div>
</div>
<!-- Usage Stats -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Nutzung</h2>
<div class="flex gap-1">
{#each ['day', 'week', 'month', 'year'] as const as tf}
<button
class="px-3 py-1 text-xs rounded-lg transition-colors"
class:bg-primary={activeTimeframe === tf}
class:text-primary-foreground={activeTimeframe === tf}
class:text-muted-foreground={activeTimeframe !== tf}
class:hover:bg-muted={activeTimeframe !== tf}
onclick={() => changeTimeframe(tf)}
>
{timeframeLabels[tf]}
</button>
{/each}
</div>
</div>
{#if tokensStore.loading}
<div class="text-center py-8 text-muted-foreground">Lade Statistiken...</div>
{:else if tokensStore.stats}
<div class="space-y-4">
<div class="text-center py-4">
<span class="text-3xl font-bold text-foreground">
{tokensStore.stats.totalUsed.toLocaleString()}
</span>
<p class="text-sm text-muted-foreground mt-1">Tokens verbraucht</p>
</div>
{#if Object.keys(tokensStore.stats.byModel).length > 0}
<div>
<h3 class="text-sm font-medium text-foreground mb-2">Nach Modell</h3>
<div class="space-y-2">
{#each Object.entries(tokensStore.stats.byModel) as [model, count]}
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">{model}</span>
<span class="text-sm font-medium text-foreground tabular-nums">
{(count as number).toLocaleString()}
</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<p class="text-center text-muted-foreground py-4">Keine Daten vorhanden</p>
{/if}
</div>
<!-- Transaction History -->
<div class="card p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Transaktionshistorie</h2>
{#if tokensStore.transactions.length > 0}
<div class="divide-y divide-border">
{#each tokensStore.transactions as tx}
<div class="py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-1.5 rounded-full {tx.amount > 0 ? 'bg-green-500/10' : 'bg-red-500/10'}">
{#if tx.amount > 0}
<ArrowDown size={14} class="text-green-500" />
{:else}
<ArrowUp size={14} class="text-red-500" />
{/if}
</div>
<div>
<p class="text-sm font-medium text-foreground">
{formatTransactionType(tx.transaction_type)}
</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
{#if tx.model_used}
<span>{tx.model_used}</span>
{/if}
<span
>{new Date(tx.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}</span
>
</div>
{#if tx.prompt_tokens || tx.completion_tokens}
<p class="text-xs text-muted-foreground mt-0.5">
Input: {tx.prompt_tokens?.toLocaleString()} + Output: {tx.completion_tokens?.toLocaleString()}
= {tx.total_tokens?.toLocaleString()}
</p>
{/if}
</div>
</div>
<span
class="text-sm font-semibold tabular-nums {tx.amount > 0
? 'text-green-500'
: 'text-red-500'}"
>
{tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString()}
</span>
</div>
{/each}
</div>
{:else}
<p class="text-center text-muted-foreground py-8">Noch keine Transaktionen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ContextLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="Context"
logo={ContextLogo}
primaryColor="#0ea5e9"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f0f9ff"
darkBackground="#0c1222"
/>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { ContextLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
if (queryRedirect) return queryRedirect;
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
const translations = $derived(getLoginTranslations($locale || 'de'));
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | Context</title>
</svelte:head>
<LoginPage
appName="Context"
logo={ContextLogo}
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f0f9ff"
darkBackground="#0c1222"
{translations}
{verified}
{initialEmail}
/>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { ContextLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const redirectTo = $derived.by(() => {
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | Context</title>
</svelte:head>
<RegisterPage
appName="Context"
logo={ContextLogo}
primaryColor="#0ea5e9"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect={redirectTo}
loginPath="/login"
lightBackground="#f0f9ff"
darkBackground="#0c1222"
{translations}
/>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { isLoading as isLocaleLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props();
let loading = $state(true);
onMount(() => {
const cleanupErrorHandler = setupGlobalErrorHandler();
const init = async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
<ToastContainer />
{#if $isLocaleLoading || loading}
<AppLoadingSkeleton />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
service: 'context-web',
timestamp: new Date().toISOString(),
});
};

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { onMount } from 'svelte';
let isOnline = $state(false);
onMount(() => {
isOnline = navigator.onLine;
const handleOnline = () => {
isOnline = true;
setTimeout(() => {
window.location.href = '/';
}, 1000);
};
const handleOffline = () => {
isOnline = false;
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
});
</script>
<svelte:head>
<title>Offline - Context</title>
</svelte:head>
<div class="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div class="text-center max-w-md">
<div class="mb-8">
<svg
class="w-24 h-24 mx-auto text-slate-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a5 5 0 01-.354-7.072L8.95 7.636m1.414 5.657L7.535 16.12m8.485 0a5 5 0 01-7.07 0"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18" />
</svg>
</div>
<h1 class="text-2xl font-bold text-white mb-4">
{isOnline ? 'Verbindung wiederhergestellt!' : 'Du bist offline'}
</h1>
<p class="text-slate-400 mb-8">
{#if isOnline}
Du wirst gleich weitergeleitet...
{:else}
Einige Funktionen sind offline nicht verfügbar. Bitte überprüfe deine Internetverbindung.
{/if}
</p>
{#if !isOnline}
<div class="space-y-4">
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-sky-600 hover:bg-sky-700 text-white font-medium rounded-lg transition-colors"
>
Zur Startseite
</a>
<button
onclick={() => window.location.reload()}
class="block w-full px-6 py-3 text-slate-400 hover:text-white transition-colors"
>
Erneut versuchen
</button>
</div>
{:else}
<div class="flex items-center justify-center text-green-400">
<svg class="w-5 h-5 mr-2 animate-spin" 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>
Weiterleitung...
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,30 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [
sveltekit(),
SvelteKitPWA(
createPWAConfig({
name: 'Context - Wissensmanagement',
shortName: 'Context',
description: 'AI-gestütztes Dokumenten- und Wissensmanagement',
themeColor: '#0ea5e9',
preset: 'minimal',
})
),
],
server: {
port: 5192,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES],
},
});