mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
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:
parent
30ee708a6f
commit
2ea7bb7a18
47 changed files with 3904 additions and 0 deletions
10
apps/context/apps/web/src/app.css
Normal file
10
apps/context/apps/web/src/app.css
Normal 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";
|
||||
12
apps/context/apps/web/src/app.html
Normal file
12
apps/context/apps/web/src/app.html
Normal 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>
|
||||
222
apps/context/apps/web/src/lib/components/AIToolbar.svelte
Normal file
222
apps/context/apps/web/src/lib/components/AIToolbar.svelte
Normal 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>
|
||||
130
apps/context/apps/web/src/lib/components/BatchCreateModal.svelte
Normal file
130
apps/context/apps/web/src/lib/components/BatchCreateModal.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
93
apps/context/apps/web/src/lib/components/DocumentCard.svelte
Normal file
93
apps/context/apps/web/src/lib/components/DocumentCard.svelte
Normal 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>
|
||||
321
apps/context/apps/web/src/lib/components/DocumentEditor.svelte
Normal file
321
apps/context/apps/web/src/lib/components/DocumentEditor.svelte
Normal 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>
|
||||
135
apps/context/apps/web/src/lib/components/MentionInput.svelte
Normal file
135
apps/context/apps/web/src/lib/components/MentionInput.svelte
Normal 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>
|
||||
85
apps/context/apps/web/src/lib/components/SpaceCard.svelte
Normal file
85
apps/context/apps/web/src/lib/components/SpaceCard.svelte
Normal 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>
|
||||
104
apps/context/apps/web/src/lib/components/VersionNavigator.svelte
Normal file
104
apps/context/apps/web/src/lib/components/VersionNavigator.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
11
apps/context/apps/web/src/lib/config/editor.ts
Normal file
11
apps/context/apps/web/src/lib/config/editor.ts
Normal 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_',
|
||||
},
|
||||
};
|
||||
40
apps/context/apps/web/src/lib/i18n/index.ts
Normal file
40
apps/context/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
68
apps/context/apps/web/src/lib/i18n/locales/de.json
Normal file
68
apps/context/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
68
apps/context/apps/web/src/lib/i18n/locales/en.json
Normal file
68
apps/context/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
203
apps/context/apps/web/src/lib/stores/documents.svelte.ts
Normal file
203
apps/context/apps/web/src/lib/stores/documents.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
5
apps/context/apps/web/src/lib/stores/navigation.ts
Normal file
5
apps/context/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'context',
|
||||
});
|
||||
72
apps/context/apps/web/src/lib/stores/spaces.svelte.ts
Normal file
72
apps/context/apps/web/src/lib/stores/spaces.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
6
apps/context/apps/web/src/lib/stores/theme.svelte.ts
Normal file
6
apps/context/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'context',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
64
apps/context/apps/web/src/lib/stores/tokens.svelte.ts
Normal file
64
apps/context/apps/web/src/lib/stores/tokens.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
18
apps/context/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
18
apps/context/apps/web/src/lib/stores/user-settings.svelte.ts
Normal 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(),
|
||||
});
|
||||
87
apps/context/apps/web/src/lib/types.ts
Normal file
87
apps/context/apps/web/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
62
apps/context/apps/web/src/lib/utils/markdown.ts
Normal file
62
apps/context/apps/web/src/lib/utils/markdown.ts
Normal 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;
|
||||
}
|
||||
49
apps/context/apps/web/src/lib/utils/text.ts
Normal file
49
apps/context/apps/web/src/lib/utils/text.ts
Normal 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) + '...';
|
||||
}
|
||||
305
apps/context/apps/web/src/routes/(app)/+layout.svelte
Normal file
305
apps/context/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
131
apps/context/apps/web/src/routes/(app)/+page.svelte
Normal file
131
apps/context/apps/web/src/routes/(app)/+page.svelte
Normal 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>
|
||||
187
apps/context/apps/web/src/routes/(app)/documents/+page.svelte
Normal file
187
apps/context/apps/web/src/routes/(app)/documents/+page.svelte
Normal 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)}
|
||||
/>
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
24
apps/context/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
24
apps/context/apps/web/src/routes/(app)/feedback/+page.svelte
Normal 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} />
|
||||
13
apps/context/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
13
apps/context/apps/web/src/routes/(app)/mana/+page.svelte
Normal 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} />
|
||||
12
apps/context/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
12
apps/context/apps/web/src/routes/(app)/profile/+page.svelte
Normal 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" />
|
||||
26
apps/context/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
26
apps/context/apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
142
apps/context/apps/web/src/routes/(app)/spaces/+page.svelte
Normal file
142
apps/context/apps/web/src/routes/(app)/spaces/+page.svelte
Normal 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)}
|
||||
/>
|
||||
276
apps/context/apps/web/src/routes/(app)/spaces/[id]/+page.svelte
Normal file
276
apps/context/apps/web/src/routes/(app)/spaces/[id]/+page.svelte
Normal 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)}
|
||||
/>
|
||||
29
apps/context/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
29
apps/context/apps/web/src/routes/(app)/themes/+page.svelte
Normal 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>
|
||||
178
apps/context/apps/web/src/routes/(app)/tokens/+page.svelte
Normal file
178
apps/context/apps/web/src/routes/(app)/tokens/+page.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
62
apps/context/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
62
apps/context/apps/web/src/routes/(auth)/login/+page.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
39
apps/context/apps/web/src/routes/+layout.svelte
Normal file
39
apps/context/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
10
apps/context/apps/web/src/routes/health/+server.ts
Normal file
10
apps/context/apps/web/src/routes/health/+server.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
96
apps/context/apps/web/src/routes/offline/+page.svelte
Normal file
96
apps/context/apps/web/src/routes/offline/+page.svelte
Normal 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>
|
||||
15
apps/context/apps/web/svelte.config.js
Normal file
15
apps/context/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/context/apps/web/tsconfig.json
Normal file
14
apps/context/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
apps/context/apps/web/vite.config.ts
Normal file
30
apps/context/apps/web/vite.config.ts
Normal 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],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue