refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,9 @@
# Mana Core Auth Configuration
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
# Chat Backend API
PUBLIC_BACKEND_URL=http://localhost:3002

View file

@ -0,0 +1,46 @@
{
"name": "@chat/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
},
"dependencies": {
"@chat/types": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.81.1",
"marked": "^17.0.0"
}
}

View file

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
};

View file

@ -0,0 +1,8 @@
@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";

18
apps/chat/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
// Auth is now handled client-side via Mana Core Auth
}
interface PageData {
pathname?: string;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

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

View file

@ -0,0 +1,10 @@
/**
* Server Hooks for SvelteKit
* Auth is now handled client-side via Mana Core Auth
*/
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
};

View file

@ -0,0 +1,82 @@
<script lang="ts">
interface Props {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
let { onSend, disabled = false, placeholder = 'Nachricht eingeben...' }: Props = $props();
let inputValue = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
function handleSubmit() {
const trimmed = inputValue.trim();
if (trimmed && !disabled) {
onSend(trimmed);
inputValue = '';
// Reset textarea height
if (textareaEl) {
textareaEl.style.height = 'auto';
}
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function handleInput() {
// Auto-resize textarea
if (textareaEl) {
textareaEl.style.height = 'auto';
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 200) + 'px';
}
}
</script>
<div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<div class="flex items-end gap-3 max-w-4xl mx-auto">
<div class="flex-1 relative">
<textarea
bind:this={textareaEl}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInput}
{placeholder}
{disabled}
rows="1"
class="w-full resize-none rounded-xl border border-gray-300 dark:border-gray-600
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100
px-4 py-3 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed
placeholder:text-gray-500 dark:placeholder:text-gray-400"
></textarea>
</div>
<button
onclick={handleSubmit}
disabled={disabled || !inputValue.trim()}
aria-label="Nachricht senden"
class="flex-shrink-0 p-3 rounded-xl bg-blue-600 text-white
hover:bg-blue-700 active:bg-blue-800
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600
transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
Enter zum Senden, Shift+Enter für neue Zeile
</p>
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { page } from '$app/stores';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { Conversation } from '@chat/types';
interface Props {
conversations: Conversation[];
isLoading?: boolean;
}
let { conversations, isLoading = false }: Props = $props();
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Gestern';
} else if (diffDays < 7) {
return date.toLocaleDateString('de-DE', { weekday: 'short' });
} else {
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
}
function truncateTitle(title: string, maxLength: number = 30): string {
if (title.length <= maxLength) return title;
return title.substring(0, maxLength - 3) + '...';
}
</script>
<div class="flex flex-col h-full">
<!-- New Chat Button -->
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<a
href="/chat"
class="flex items-center justify-center gap-2 w-full px-4 py-2.5
bg-blue-600 hover:bg-blue-700 text-white rounded-lg
font-medium transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuer Chat
</a>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div class="animate-spin w-6 h-6 border-2 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if conversations.length === 0}
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">Keine Konversationen</p>
<p class="text-xs mt-1">Starte einen neuen Chat</p>
</div>
{:else}
<div class="py-2">
{#each conversations as conv (conv.id)}
{@const isActive = $page.params.id === conv.id}
<a
href="/chat/{conv.id}"
class="block px-3 py-2 mx-2 rounded-lg transition-colors
{isActive
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate">
{truncateTitle(conv.title || 'Neue Konversation')}
</span>
<span class="text-xs text-gray-500 dark:text-gray-500 flex-shrink-0">
{formatDate(conv.updated_at || conv.created_at)}
</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { marked } from 'marked';
import type { Message } from '@chat/types';
interface Props {
message: Message;
}
let { message }: Props = $props();
const isUser = $derived(message.sender === 'user');
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
const htmlContent = $derived(
isUser ? message.message_text : marked.parse(message.message_text)
);
const formattedTime = $derived(
new Date(message.created_at).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
);
</script>
<div class="flex {isUser ? 'justify-end' : 'justify-start'} mb-4">
<div
class="max-w-[80%] rounded-2xl px-4 py-3 {isUser
? 'bg-blue-600 text-white rounded-br-md'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-md'}"
>
{#if isUser}
<p class="whitespace-pre-wrap">{message.message_text}</p>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html htmlContent}
</div>
{/if}
<div
class="text-xs mt-1 {isUser
? 'text-blue-200'
: 'text-gray-500 dark:text-gray-400'}"
>
{formattedTime}
</div>
</div>
</div>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Message } from '@chat/types';
import MessageBubble from './MessageBubble.svelte';
import TypingIndicator from './TypingIndicator.svelte';
interface Props {
messages: Message[];
isTyping?: boolean;
}
let { messages, isTyping = false }: Props = $props();
let containerEl: HTMLDivElement | undefined = $state();
// Auto-scroll to bottom when messages change
$effect(() => {
if (messages.length > 0 && containerEl) {
scrollToBottom();
}
});
function scrollToBottom() {
if (containerEl) {
containerEl.scrollTop = containerEl.scrollHeight;
}
}
onMount(() => {
scrollToBottom();
});
</script>
<div
bind:this={containerEl}
class="flex-1 overflow-y-auto px-4 py-6"
>
{#if messages.length === 0}
<div class="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
<svg
class="w-16 h-16 mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p class="text-lg font-medium">Keine Nachrichten</p>
<p class="text-sm">Starte eine Konversation!</p>
</div>
{:else}
{#each messages as message (message.id)}
<MessageBubble {message} />
{/each}
{#if isTyping}
<TypingIndicator />
{/if}
{/if}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import type { AIModel } from '@chat/types';
interface Props {
models: AIModel[];
selectedModelId: string;
onSelect: (modelId: string) => void;
disabled?: boolean;
}
let { models, selectedModelId, onSelect, disabled = false }: Props = $props();
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
onSelect(target.value);
}
</script>
<div class="relative">
<select
value={selectedModelId}
onchange={handleChange}
{disabled}
class="appearance-none bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
text-sm rounded-lg px-3 py-2 pr-8 border border-gray-200 dark:border-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed
cursor-pointer min-w-[160px]"
>
{#if models.length === 0}
<option value="">Laden...</option>
{:else}
{#each models as model (model.id)}
<option value={model.id}>{model.name}</option>
{/each}
{/if}
</select>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"
>
<svg
class="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts">
// Typing indicator with animated dots
</script>
<div class="flex justify-start mb-4">
<div
class="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3"
>
<div class="flex items-center gap-1">
<div
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style="animation-delay: 0ms"
></div>
<div
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style="animation-delay: 150ms"
></div>
<div
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style="animation-delay: 300ms"
></div>
</div>
</div>
</div>
<style>
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
</style>

View file

@ -0,0 +1,162 @@
<script lang="ts">
import type { Space } from '@chat/types';
interface Props {
space: Space;
isOwner: boolean;
onSelect: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onLeave: (id: string) => void;
}
let { space, isOwner, onSelect, onEdit, onDelete, onLeave }: Props = $props();
let showMenu = $state(false);
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
showMenu = !showMenu;
}
function handleAction(action: () => void) {
showMenu = false;
action();
}
</script>
<svelte:window onclick={() => (showMenu = false)} />
<div
class="group relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
shadow-sm hover:shadow-md transition-all cursor-pointer"
onclick={() => onSelect(space.id)}
onkeydown={(e) => e.key === 'Enter' && onSelect(space.id)}
role="button"
tabindex="0"
>
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<svg class="w-5 h-5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">
{space.name}
</h3>
{#if isOwner}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded">
Besitzer
</span>
{/if}
</div>
{#if space.description}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
{space.description}
</p>
{/if}
<p class="text-xs text-gray-500 dark:text-gray-500">
Erstellt: {formatDate(space.created_at)}
</p>
</div>
<!-- Options Menu -->
<div class="relative">
<button
onclick={handleMenuClick}
class="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Optionen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showMenu}
<div
class="absolute right-0 top-full mt-1 py-1 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg
border border-gray-200 dark:border-gray-700 z-10"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
role="menu"
tabindex="-1"
>
{#if isOwner}
<button
onclick={() => handleAction(() => onEdit(space.id))}
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-gray-700"
role="menuitem"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Einstellungen
</button>
<button
onclick={() => handleAction(() => onDelete(space.id))}
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400
hover:bg-red-50 dark:hover:bg-red-900/20"
role="menuitem"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
{:else}
<button
onclick={() => handleAction(() => onLeave(space.id))}
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400
hover:bg-red-50 dark:hover:bg-red-900/20"
role="menuitem"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Verlassen
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,102 @@
<script lang="ts">
import type { Space } from '@chat/types';
interface Props {
space?: Space;
onSubmit: (data: { name: string; description?: string }) => void;
onCancel: () => void;
}
let { space, onSubmit, onCancel }: Props = $props();
let name = $state(space?.name ?? '');
let description = $state(space?.description ?? '');
let errors = $state<{ name?: string }>({});
const isEditMode = !!space?.id;
function validateForm(): boolean {
const newErrors: { name?: string } = {};
if (!name.trim()) {
newErrors.name = 'Bitte gib einen Namen ein.';
}
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
function handleSubmit() {
if (!validateForm()) return;
onSubmit({
name: name.trim(),
description: description.trim() || undefined,
});
}
</script>
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl max-w-lg mx-auto">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
{isEditMode ? 'Space bearbeiten' : 'Neuen Space erstellen'}
</h2>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-5">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name *
</label>
<input
type="text"
id="name"
bind:value={name}
maxlength={100}
placeholder="Name des Spaces"
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
text-gray-900 dark:text-white placeholder-gray-500
{errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-500">{errors.name}</p>
{/if}
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung (optional)
</label>
<textarea
id="description"
bind:value={description}
maxlength={500}
rows={3}
placeholder="Worum geht es in diesem Space?"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
</div>
<!-- Buttons -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={onCancel}
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
rounded-lg font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
Abbrechen
</button>
<button
type="submit"
class="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
{isEditMode ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import type { Template } from '@chat/types';
interface Props {
template: Template;
onUse: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onSetDefault: (id: string) => void;
}
let { template, onUse, onEdit, onDelete, onSetDefault }: Props = $props();
function truncatePrompt(text: string, maxLength: number = 80): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
</script>
<div
class="group relative flex rounded-xl overflow-hidden bg-white dark:bg-gray-800 shadow-sm hover:shadow-md transition-all
{template.is_default ? 'ring-2 ring-blue-500' : 'border border-gray-200 dark:border-gray-700'}"
>
<!-- Color Indicator -->
<div class="w-2 flex-shrink-0" style="background-color: {template.color}"></div>
<!-- Content -->
<div class="flex-1 p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">
{template.name}
</h3>
{#if template.is_default}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-500 text-white rounded">
Standard
</span>
{/if}
</div>
{#if template.description}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
{template.description}
</p>
{/if}
<p class="text-xs text-gray-500 dark:text-gray-500 italic line-clamp-2">
{truncatePrompt(template.system_prompt)}
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{#if !template.is_default}
<button
onclick={() => onSetDefault(template.id)}
class="p-1.5 text-gray-500 hover:text-yellow-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Als Standard setzen"
aria-label="Als Standard setzen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
</button>
{/if}
<button
onclick={() => onEdit(template.id)}
class="p-1.5 text-gray-500 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Bearbeiten"
aria-label="Bearbeiten"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onclick={() => onDelete(template.id)}
class="p-1.5 text-gray-500 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Löschen"
aria-label="Löschen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Use Button -->
<button
onclick={() => onUse(template.id)}
class="mt-3 w-full py-2 px-3 text-sm font-medium text-white rounded-lg transition-colors"
style="background-color: {template.color}"
>
Chat starten
</button>
</div>
</div>

View file

@ -0,0 +1,265 @@
<script lang="ts">
import type { Template, AIModel } from '@chat/types';
import { chatService } from '$lib/services/chat';
import { onMount } from 'svelte';
interface Props {
template?: Template;
onSubmit: (data: Partial<Template>) => void;
onCancel: () => void;
}
let { template, onSubmit, onCancel }: Props = $props();
// Available colors
const TEMPLATE_COLORS = [
'#0A84FF', // Blue
'#32D74B', // Green
'#FF375F', // Red
'#FF9F0A', // Orange
'#5E5CE6', // Purple
'#BF5AF2', // Pink
'#64D2FF', // Light Blue
'#30D158', // Green 2
'#FF453A', // Red 2
];
// Form state
let name = $state(template?.name ?? '');
let description = $state(template?.description ?? '');
let systemPrompt = $state(template?.system_prompt ?? '');
let initialQuestion = $state(template?.initial_question ?? '');
let selectedColor = $state(template?.color ?? TEMPLATE_COLORS[0]);
let selectedModelId = $state(template?.model_id ?? '');
let documentMode = $state(template?.document_mode ?? false);
// Models
let models = $state<AIModel[]>([]);
// Validation
let errors = $state<{ name?: string; systemPrompt?: string }>({});
const isEditMode = !!template?.id;
onMount(async () => {
models = await chatService.getModels();
});
function validateForm(): boolean {
const newErrors: { name?: string; systemPrompt?: string } = {};
if (!name.trim()) {
newErrors.name = 'Bitte gib einen Namen ein.';
}
if (!systemPrompt.trim()) {
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
}
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
function handleSubmit() {
if (!validateForm()) return;
onSubmit({
id: template?.id,
name,
description: description.trim() || null,
system_prompt: systemPrompt,
initial_question: initialQuestion.trim() || null,
color: selectedColor,
model_id: selectedModelId || null,
document_mode: documentMode,
});
}
</script>
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl max-w-2xl mx-auto">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
</h2>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-5">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name *
</label>
<input
type="text"
id="name"
bind:value={name}
maxlength={50}
placeholder="Name der Vorlage"
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
text-gray-900 dark:text-white placeholder-gray-500
{errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-500">{errors.name}</p>
{/if}
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung (optional)
</label>
<textarea
id="description"
bind:value={description}
maxlength={200}
rows={2}
placeholder="Kurze Beschreibung dieser Vorlage"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
</div>
<!-- System Prompt -->
<div>
<label for="systemPrompt" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
System-Prompt *
</label>
<textarea
id="systemPrompt"
bind:value={systemPrompt}
rows={5}
placeholder="System-Prompt für die KI"
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
text-gray-900 dark:text-white placeholder-gray-500
{errors.systemPrompt ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
{#if errors.systemPrompt}
<p class="mt-1 text-sm text-red-500">{errors.systemPrompt}</p>
{:else}
<p class="mt-1 text-xs text-gray-500">
Der System-Prompt definiert die Rolle und das Verhalten der KI.
</p>
{/if}
</div>
<!-- Initial Question -->
<div>
<label for="initialQuestion" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Beispielfrage (optional)
</label>
<textarea
id="initialQuestion"
bind:value={initialQuestion}
rows={2}
placeholder="Beispiel für eine passende Frage oder Anweisung"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
<p class="mt-1 text-xs text-gray-500">
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
</p>
</div>
<!-- Color -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Farbe
</label>
<div class="flex flex-wrap gap-2">
{#each TEMPLATE_COLORS as color}
<button
type="button"
onclick={() => (selectedColor = color)}
class="w-8 h-8 rounded-full flex items-center justify-center transition-transform hover:scale-110
{selectedColor === color ? 'ring-2 ring-offset-2 ring-gray-900 dark:ring-white' : ''}"
style="background-color: {color}"
aria-label="Farbe {color}"
>
{#if selectedColor === color}
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
<!-- Model -->
<div>
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Bevorzugtes Modell (optional)
</label>
<select
id="model"
bind:value={selectedModelId}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Kein Modell ausgewählt</option>
{#each models as model}
<option value={model.id}>{model.name}</option>
{/each}
</select>
<p class="mt-1 text-xs text-gray-500">
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
</p>
</div>
<!-- Document Mode -->
<div>
<button
type="button"
onclick={() => (documentMode = !documentMode)}
class="w-full flex items-center justify-between p-4 border rounded-lg transition-colors
{documentMode
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800'}"
>
<div class="text-left">
<p class="font-medium text-gray-900 dark:text-white">Dokumentmodus aktivieren</p>
<p class="text-xs text-gray-500 mt-0.5">
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
</p>
</div>
<div
class="w-6 h-6 rounded-full flex items-center justify-center
{documentMode ? 'bg-blue-500' : 'bg-gray-400'}"
>
{#if documentMode}
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{:else}
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}
</div>
</button>
</div>
<!-- Buttons -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={onCancel}
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
rounded-lg font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
Abbrechen
</button>
<button
type="submit"
class="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
{isEditMode ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,682 @@
/**
* API Client for Chat Backend
*
* This replaces direct Supabase calls with backend API calls.
* All database operations now go through the NestJS backend.
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {},
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
// Get token from localStorage if not provided
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('mana_token') || undefined;
}
if (!authToken) {
return { data: null, error: new Error('No authentication token') };
}
try {
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
// ============ Conversation API ============
export type Conversation = {
id: string;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
title?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
updatedAt: string;
};
export const conversationApi = {
async getConversations(spaceId?: string): Promise<Conversation[]> {
const query = spaceId ? `?spaceId=${spaceId}` : '';
const { data, error } = await fetchApi<Conversation[]>(`/conversations${query}`);
if (error) {
console.error('Error loading conversations:', error);
return [];
}
return data || [];
},
async getArchivedConversations(): Promise<Conversation[]> {
const { data, error } = await fetchApi<Conversation[]>('/conversations/archived');
if (error) {
console.error('Error loading archived conversations:', error);
return [];
}
return data || [];
},
async getConversation(id: string): Promise<Conversation | null> {
const { data, error } = await fetchApi<Conversation>(`/conversations/${id}`);
if (error) {
console.error('Error loading conversation:', error);
return null;
}
return data;
},
async getMessages(conversationId: string): Promise<Message[]> {
const { data, error } = await fetchApi<Message[]>(
`/conversations/${conversationId}/messages`,
);
if (error) {
console.error('Error loading messages:', error);
return [];
}
return data || [];
},
async createConversation(options: {
modelId: string;
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
}): Promise<Conversation | null> {
const { data, error } = await fetchApi<Conversation>('/conversations', {
method: 'POST',
body: options,
});
if (error) {
console.error('Error creating conversation:', error);
return null;
}
return data;
},
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): Promise<Message | null> {
const { data, error } = await fetchApi<Message>(
`/conversations/${conversationId}/messages`,
{
method: 'POST',
body: { sender, messageText },
},
);
if (error) {
console.error('Error adding message:', error);
return null;
}
return data;
},
async updateTitle(conversationId: string, title: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/title`,
{
method: 'PATCH',
body: { title },
},
);
if (error) {
console.error('Error updating title:', error);
return false;
}
return true;
},
async archiveConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/archive`,
{ method: 'PATCH' },
);
if (error) {
console.error('Error archiving conversation:', error);
return false;
}
return true;
},
async unarchiveConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/unarchive`,
{ method: 'PATCH' },
);
if (error) {
console.error('Error unarchiving conversation:', error);
return false;
}
return true;
},
async deleteConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(
`/conversations/${conversationId}`,
{ method: 'DELETE' },
);
if (error) {
console.error('Error deleting conversation:', error);
return false;
}
return true;
},
};
// ============ Template API ============
export type Template = {
id: string;
userId: string;
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color: string;
isDefault: boolean;
documentMode: boolean;
createdAt: string;
updatedAt: string;
};
export const templateApi = {
async getTemplates(): Promise<Template[]> {
const { data, error } = await fetchApi<Template[]>('/templates');
if (error) {
console.error('Error loading templates:', error);
return [];
}
return data || [];
},
async getTemplate(id: string): Promise<Template | null> {
const { data, error } = await fetchApi<Template>(`/templates/${id}`);
if (error) {
console.error('Error loading template:', error);
return null;
}
return data;
},
async getDefaultTemplate(): Promise<Template | null> {
const { data, error } = await fetchApi<Template | null>('/templates/default');
if (error) {
console.error('Error loading default template:', error);
return null;
}
return data;
},
async createTemplate(template: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
}): Promise<Template | null> {
const { data, error } = await fetchApi<Template>('/templates', {
method: 'POST',
body: template,
});
if (error) {
console.error('Error creating template:', error);
return null;
}
return data;
},
async updateTemplate(
id: string,
updates: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): Promise<boolean> {
const { error } = await fetchApi<Template>(`/templates/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) {
console.error('Error updating template:', error);
return false;
}
return true;
},
async setDefaultTemplate(id: string): Promise<boolean> {
const { error } = await fetchApi<Template>(`/templates/${id}/default`, {
method: 'PATCH',
});
if (error) {
console.error('Error setting default template:', error);
return false;
}
return true;
},
async deleteTemplate(id: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/templates/${id}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting template:', error);
return false;
}
return true;
},
};
// ============ Space API ============
export type Space = {
id: string;
ownerId: string;
name: string;
description?: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type SpaceMember = {
id: string;
spaceId: string;
userId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
invitationStatus: 'pending' | 'accepted' | 'declined';
invitedBy?: string;
invitedAt: string;
joinedAt?: string;
createdAt: string;
updatedAt: string;
};
export const spaceApi = {
async getUserSpaces(): Promise<Space[]> {
const { data, error } = await fetchApi<Space[]>('/spaces');
if (error) {
console.error('Error loading spaces:', error);
return [];
}
return data || [];
},
async getOwnedSpaces(): Promise<Space[]> {
const { data, error } = await fetchApi<Space[]>('/spaces/owned');
if (error) {
console.error('Error loading owned spaces:', error);
return [];
}
return data || [];
},
async getSpace(id: string): Promise<Space | null> {
const { data, error } = await fetchApi<Space>(`/spaces/${id}`);
if (error) {
console.error('Error loading space:', error);
return null;
}
return data;
},
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const { data, error } = await fetchApi<SpaceMember[]>(`/spaces/${spaceId}/members`);
if (error) {
console.error('Error loading space members:', error);
return [];
}
return data || [];
},
async getUserRoleInSpace(
spaceId: string,
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
const { data, error } = await fetchApi<{
role: 'owner' | 'admin' | 'member' | 'viewer' | null;
}>(`/spaces/${spaceId}/role`);
if (error) {
console.error('Error getting user role:', error);
return null;
}
return data?.role || null;
},
async getPendingInvitations(): Promise<
Array<{ invitation: SpaceMember; space: Space }>
> {
const { data, error } = await fetchApi<
Array<{ invitation: SpaceMember; space: Space }>
>('/spaces/invitations');
if (error) {
console.error('Error loading invitations:', error);
return [];
}
return data || [];
},
async createSpace(name: string, description?: string): Promise<Space | null> {
const { data, error } = await fetchApi<Space>('/spaces', {
method: 'POST',
body: { name, description },
});
if (error) {
console.error('Error creating space:', error);
return null;
}
return data;
},
async updateSpace(
id: string,
updates: { name?: string; description?: string; isArchived?: boolean },
): Promise<boolean> {
const { error } = await fetchApi<Space>(`/spaces/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
},
async deleteSpace(id: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/spaces/${id}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
},
async inviteUser(
spaceId: string,
userId: string,
role?: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(`/spaces/${spaceId}/invite`, {
method: 'POST',
body: { userId, role },
});
if (error) {
console.error('Error inviting user:', error);
return false;
}
return true;
},
async respondToInvitation(
spaceId: string,
status: 'accepted' | 'declined',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(`/spaces/${spaceId}/respond`, {
method: 'POST',
body: { status },
});
if (error) {
console.error('Error responding to invitation:', error);
return false;
}
return true;
},
async removeMember(spaceId: string, userId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(
`/spaces/${spaceId}/members/${userId}`,
{ method: 'DELETE' },
);
if (error) {
console.error('Error removing member:', error);
return false;
}
return true;
},
async changeMemberRole(
spaceId: string,
userId: string,
role: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(
`/spaces/${spaceId}/members/${userId}/role`,
{
method: 'PATCH',
body: { role },
},
);
if (error) {
console.error('Error changing member role:', error);
return false;
}
return true;
},
};
// ============ Document API ============
export type Document = {
id: string;
conversationId: string;
version: number;
content: string;
createdAt: string;
updatedAt: string;
};
export const documentApi = {
async getLatestDocument(conversationId: string): Promise<Document | null> {
const { data, error } = await fetchApi<Document | null>(
`/documents/conversation/${conversationId}`,
);
if (error) {
console.error('Error loading document:', error);
return null;
}
return data;
},
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
const { data, error } = await fetchApi<Document[]>(
`/documents/conversation/${conversationId}/versions`,
);
if (error) {
console.error('Error loading document versions:', error);
return [];
}
return data || [];
},
async hasDocument(conversationId: string): Promise<boolean> {
const { data, error } = await fetchApi<{ exists: boolean }>(
`/documents/conversation/${conversationId}/exists`,
);
if (error) {
console.error('Error checking document:', error);
return false;
}
return data?.exists || false;
},
async createDocument(
conversationId: string,
content: string,
): Promise<Document | null> {
const { data, error } = await fetchApi<Document>(
`/documents/conversation/${conversationId}`,
{
method: 'POST',
body: { content },
},
);
if (error) {
console.error('Error creating document:', error);
return null;
}
return data;
},
async createDocumentVersion(
conversationId: string,
content: string,
): Promise<Document | null> {
const { data, error } = await fetchApi<Document>(
`/documents/conversation/${conversationId}/version`,
{
method: 'POST',
body: { content },
},
);
if (error) {
console.error('Error creating document version:', error);
return null;
}
return data;
},
async deleteDocumentVersion(documentId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/documents/${documentId}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting document:', error);
return false;
}
return true;
},
};
// ============ Model API ============
export type Model = {
id: string;
name: string;
description?: string;
provider: string;
parameters?: {
deployment?: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
};
isActive: boolean;
createdAt: string;
updatedAt: string;
};
export const modelApi = {
async getModels(): Promise<Model[]> {
const { data, error } = await fetchApi<Model[]>('/models');
if (error) {
console.error('Error loading models:', error);
return [];
}
return data || [];
},
async getModel(id: string): Promise<Model | null> {
const { data, error } = await fetchApi<Model>(`/models/${id}`);
if (error) {
console.error('Error loading model:', error);
return null;
}
return data;
},
};
// ============ Chat API ============
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
export type ChatCompletionResponse = {
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export const chatApi = {
async createCompletion(options: {
messages: ChatMessage[];
modelId: string;
temperature?: number;
maxTokens?: number;
}): Promise<ChatCompletionResponse | null> {
const { data, error } = await fetchApi<ChatCompletionResponse>('/chat/completions', {
method: 'POST',
body: options,
});
if (error) {
console.error('Error creating completion:', error);
return null;
}
return data;
},
};

View file

@ -0,0 +1,35 @@
/**
* Chat Service - AI Completions via Backend API
*/
import { chatApi, modelApi, type ChatMessage, type ChatCompletionResponse, type Model } from './api';
export type { ChatMessage, ChatCompletionResponse };
export interface ChatCompletionRequest {
messages: ChatMessage[];
modelId: string;
temperature?: number;
maxTokens?: number;
}
export const chatService = {
/**
* Get available AI models
*/
async getModels(): Promise<Model[]> {
return modelApi.getModels();
},
/**
* Send chat completion request
*/
async createCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse | null> {
return chatApi.createCompletion({
messages: request.messages,
modelId: request.modelId,
temperature: request.temperature ?? 0.7,
maxTokens: request.maxTokens ?? 1000,
});
},
};

View file

@ -0,0 +1,198 @@
/**
* Conversation Service - CRUD operations via Backend API
*/
import {
conversationApi,
chatApi,
type Conversation,
type Message,
type ChatMessage,
} from './api';
export type { Conversation, Message };
export const conversationService = {
/**
* Create a new conversation
*/
async createConversation(
userId: string,
modelId: string,
mode: 'free' | 'guided' | 'template' = 'free',
templateId?: string,
documentMode: boolean = false,
spaceId?: string,
): Promise<string | null> {
const conversation = await conversationApi.createConversation({
modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
});
return conversation?.id || null;
},
/**
* Get all active conversations for a user
*/
async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
return conversationApi.getConversations(spaceId);
},
/**
* Get archived conversations
*/
async getArchivedConversations(userId: string): Promise<Conversation[]> {
return conversationApi.getArchivedConversations();
},
/**
* Get a single conversation
*/
async getConversation(conversationId: string): Promise<Conversation | null> {
return conversationApi.getConversation(conversationId);
},
/**
* Get messages for a conversation
*/
async getMessages(conversationId: string): Promise<Message[]> {
return conversationApi.getMessages(conversationId);
},
/**
* Add a message to a conversation
*/
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): Promise<string | null> {
const message = await conversationApi.addMessage(conversationId, sender, messageText);
return message?.id || null;
},
/**
* Update conversation title
*/
async updateTitle(conversationId: string, title: string): Promise<boolean> {
return conversationApi.updateTitle(conversationId, title);
},
/**
* Archive a conversation
*/
async archiveConversation(conversationId: string): Promise<boolean> {
return conversationApi.archiveConversation(conversationId);
},
/**
* Unarchive a conversation
*/
async unarchiveConversation(conversationId: string): Promise<boolean> {
return conversationApi.unarchiveConversation(conversationId);
},
/**
* Delete a conversation permanently
*/
async deleteConversation(conversationId: string): Promise<boolean> {
return conversationApi.deleteConversation(conversationId);
},
/**
* Send a message and get AI response
*/
async sendMessageAndGetResponse(
conversationId: string,
userMessage: string,
modelId: string,
): Promise<{
userMessageId: string | null;
assistantMessageId: string | null;
assistantResponse: string;
title?: string;
}> {
// Add user message
const userMessageId = await this.addMessage(conversationId, 'user', userMessage);
// Load all messages for context
const messages = await this.getMessages(conversationId);
// Build chat messages for API
const chatMessages: ChatMessage[] = messages.map((m) => ({
role: m.sender === 'user' ? 'user' : m.sender === 'assistant' ? 'assistant' : 'system',
content: m.messageText,
}));
// Get AI response
const response = await chatApi.createCompletion({
messages: chatMessages,
modelId,
});
if (!response) {
return {
userMessageId,
assistantMessageId: null,
assistantResponse: 'Fehler beim Abrufen der Antwort.',
};
}
// Save assistant message
const assistantMessageId = await this.addMessage(
conversationId,
'assistant',
response.content,
);
// Generate title if this is a new conversation (first or second message)
let title: string | undefined;
if (messages.length <= 2) {
title = await this.generateTitle(userMessage);
if (title) {
await this.updateTitle(conversationId, title);
}
}
return {
userMessageId,
assistantMessageId,
assistantResponse: response.content,
title,
};
},
/**
* Generate a conversation title based on user message
*/
async generateTitle(userMessage: string): Promise<string> {
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
const response = await chatApi.createCompletion({
messages: [{ role: 'user', content: titlePrompt }],
modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
temperature: 0.3,
maxTokens: 50,
});
if (!response) {
return 'Neue Konversation';
}
// Clean up title
let title = response.content
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/g, '');
if (title.length > 100) {
title = title.substring(0, 97) + '...';
}
return title;
},
};

View file

@ -0,0 +1,90 @@
/**
* Document Service - CRUD operations via Backend API
*/
import { documentApi, conversationApi, type Document } from './api';
export type { Document };
export type DocumentWithConversation = Document & {
conversation_title: string;
};
export const documentService = {
/**
* Get all documents for a user (latest version of each)
* This requires fetching conversations first, then documents
*/
async getUserDocuments(userId: string): Promise<DocumentWithConversation[]> {
// Get all conversations (the API will filter by user)
const conversations = await conversationApi.getConversations();
// Filter to only document mode conversations
const documentConversations = conversations.filter((c) => c.documentMode);
if (documentConversations.length === 0) {
return [];
}
// For each conversation, load the latest document version
const documents: DocumentWithConversation[] = [];
for (const conv of documentConversations) {
const doc = await documentApi.getLatestDocument(conv.id);
if (doc) {
documents.push({
...doc,
conversation_title: conv.title || 'Unbenannte Konversation',
});
}
}
return documents;
},
/**
* Get the latest document for a conversation
*/
async getLatestDocument(conversationId: string): Promise<Document | null> {
return documentApi.getLatestDocument(conversationId);
},
/**
* Create a new document
*/
async createDocument(conversationId: string, content: string): Promise<Document | null> {
return documentApi.createDocument(conversationId, content);
},
/**
* Create a new version of a document
*/
async createDocumentVersion(
conversationId: string,
content: string,
): Promise<Document | null> {
return documentApi.createDocumentVersion(conversationId, content);
},
/**
* Get all versions of a document
*/
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
return documentApi.getAllDocumentVersions(conversationId);
},
/**
* Check if a document exists for a conversation
*/
async hasDocument(conversationId: string): Promise<boolean> {
return documentApi.hasDocument(conversationId);
},
/**
* Delete a specific document version
*/
async deleteDocumentVersion(documentId: string): Promise<boolean> {
return documentApi.deleteDocumentVersion(documentId);
},
};

View file

@ -0,0 +1,124 @@
/**
* Space Service - CRUD operations via Backend API
*/
import { spaceApi, type Space, type SpaceMember } from './api';
export type { Space, SpaceMember };
export const spaceService = {
/**
* Get all spaces for the current user (both owned and member of)
*/
async getUserSpaces(userId: string): Promise<Space[]> {
return spaceApi.getUserSpaces();
},
/**
* Get a single space by ID
*/
async getSpace(spaceId: string): Promise<Space | null> {
return spaceApi.getSpace(spaceId);
},
/**
* Create a new space
*/
async createSpace(space: {
name: string;
description?: string;
owner_id: string;
}): Promise<string | null> {
const result = await spaceApi.createSpace(space.name, space.description);
return result?.id || null;
},
/**
* Update a space
*/
async updateSpace(
spaceId: string,
updates: { name?: string; description?: string; isArchived?: boolean },
): Promise<boolean> {
return spaceApi.updateSpace(spaceId, updates);
},
/**
* Delete a space
*/
async deleteSpace(spaceId: string): Promise<boolean> {
return spaceApi.deleteSpace(spaceId);
},
/**
* Get members of a space
*/
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
return spaceApi.getSpaceMembers(spaceId);
},
/**
* Get user's role in a space
*/
async getUserRoleInSpace(
spaceId: string,
userId: string,
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
return spaceApi.getUserRoleInSpace(spaceId);
},
/**
* Leave a space (remove self from members)
*/
async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
return spaceApi.removeMember(spaceId, userId);
},
/**
* Invite a user to a space
*/
async inviteUserToSpace(
spaceId: string,
userId: string,
role: 'admin' | 'member' | 'viewer' = 'member',
): Promise<boolean> {
return spaceApi.inviteUser(spaceId, userId, role);
},
/**
* Respond to a space invitation
*/
async respondToInvitation(
spaceId: string,
status: 'accepted' | 'declined',
): Promise<boolean> {
return spaceApi.respondToInvitation(spaceId, status);
},
/**
* Get pending invitations for the current user
*/
async getPendingInvitations(): Promise<
Array<{ invitation: SpaceMember; space: Space }>
> {
return spaceApi.getPendingInvitations();
},
/**
* Remove a member from a space
*/
async removeMember(spaceId: string, userId: string): Promise<boolean> {
return spaceApi.removeMember(spaceId, userId);
},
/**
* Change a member's role
*/
async changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
return spaceApi.changeMemberRole(spaceId, userId, newRole);
},
};

View file

@ -0,0 +1,42 @@
/**
* Supabase Client for Chat Web App
* Uses the same Supabase instance as the mobile app
*/
import { createClient } from '@supabase/supabase-js';
import { createBrowserClient, createServerClient } from '@supabase/ssr';
import { env } from '$env/dynamic/public';
import type { Cookies } from '@sveltejs/kit';
const supabaseUrl = env.PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY || '';
/**
* Browser client for client-side operations
*/
export function createSupabaseBrowserClient() {
return createBrowserClient(supabaseUrl, supabaseAnonKey);
}
/**
* Server client for SSR operations
*/
export function createSupabaseServerClient(cookies: Cookies) {
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookies.set(name, value, { ...options, path: '/' });
});
},
},
});
}
/**
* Simple client for basic operations (no SSR)
*/
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

View file

@ -0,0 +1,86 @@
/**
* Template Service - CRUD operations via Backend API
*/
import { templateApi, type Template } from './api';
export type { Template };
export const templateService = {
/**
* Get all templates for the current user
*/
async getTemplates(userId: string): Promise<Template[]> {
return templateApi.getTemplates();
},
/**
* Get a single template by ID
*/
async getTemplate(templateId: string): Promise<Template | null> {
return templateApi.getTemplate(templateId);
},
/**
* Get the default template for the current user
*/
async getDefaultTemplate(userId: string): Promise<Template | null> {
return templateApi.getDefaultTemplate();
},
/**
* Create a new template
*/
async createTemplate(template: {
userId: string;
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
}): Promise<Template | null> {
return templateApi.createTemplate({
name: template.name,
description: template.description,
systemPrompt: template.systemPrompt,
initialQuestion: template.initialQuestion,
modelId: template.modelId,
color: template.color,
documentMode: template.documentMode,
});
},
/**
* Update an existing template
*/
async updateTemplate(
templateId: string,
updates: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): Promise<boolean> {
return templateApi.updateTemplate(templateId, updates);
},
/**
* Delete a template
*/
async deleteTemplate(templateId: string): Promise<boolean> {
return templateApi.deleteTemplate(templateId);
},
/**
* Set a template as default
*/
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
return templateApi.setDefaultTemplate(templateId);
},
};

View file

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

View file

@ -0,0 +1,125 @@
/**
* Chat Store - Manages current chat state using Svelte 5 runes
*/
import { chatService, type ChatCompletionRequest } from '$lib/services/chat';
import type { Message, AIModel, ChatMessage } from '@chat/types';
// State
let messages = $state<Message[]>([]);
let models = $state<AIModel[]>([]);
let selectedModelId = $state<string>('');
let isLoading = $state(false);
let isSending = $state(false);
let error = $state<string | null>(null);
// Temporary message counter for IDs
let messageCounter = 0;
export const chatStore = {
// Getters
get messages() {
return messages;
},
get models() {
return models;
},
get selectedModelId() {
return selectedModelId;
},
get selectedModel() {
return models.find((m) => m.id === selectedModelId) || null;
},
get isLoading() {
return isLoading;
},
get isSending() {
return isSending;
},
get error() {
return error;
},
// Actions
async loadModels() {
isLoading = true;
error = null;
try {
models = await chatService.getModels();
if (models.length > 0 && !selectedModelId) {
selectedModelId = models[0].id;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load models';
} finally {
isLoading = false;
}
},
setSelectedModel(modelId: string) {
selectedModelId = modelId;
},
async sendMessage(text: string) {
if (!text.trim() || !selectedModelId) return;
isSending = true;
error = null;
// Add user message
const userMessage: Message = {
id: `temp-${++messageCounter}`,
conversation_id: '',
sender: 'user',
message_text: text,
created_at: new Date().toISOString(),
};
messages = [...messages, userMessage];
try {
// Build chat messages for API
const chatMessages: ChatMessage[] = messages.map((m) => ({
role: m.sender === 'user' ? 'user' : 'assistant',
content: m.message_text,
}));
const request: ChatCompletionRequest = {
messages: chatMessages,
modelId: selectedModelId,
};
const response = await chatService.createCompletion(request);
if (response) {
// Add assistant message
const assistantMessage: Message = {
id: `temp-${++messageCounter}`,
conversation_id: '',
sender: 'assistant',
message_text: response.content,
created_at: new Date().toISOString(),
};
messages = [...messages, assistantMessage];
} else {
error = 'Failed to get response';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to send message';
} finally {
isSending = false;
}
},
clearMessages() {
messages = [];
messageCounter = 0;
error = null;
},
reset() {
messages = [];
messageCounter = 0;
error = null;
isSending = false;
},
};

View file

@ -0,0 +1,135 @@
/**
* Conversations Store - Manages conversation list using Svelte 5 runes
*/
import { conversationService } from '$lib/services/conversation';
import type { Conversation } from '@chat/types';
// State
let conversations = $state<Conversation[]>([]);
let archivedConversations = $state<Conversation[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const conversationsStore = {
// Getters
get conversations() {
return conversations;
},
get archivedConversations() {
return archivedConversations;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
/**
* Load conversations for a user
*/
async loadConversations(userId: string, spaceId?: string) {
isLoading = true;
error = null;
try {
conversations = await conversationService.getConversations(userId, spaceId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load conversations';
conversations = [];
} finally {
isLoading = false;
}
},
/**
* Load archived conversations
*/
async loadArchivedConversations(userId: string) {
isLoading = true;
error = null;
try {
archivedConversations = await conversationService.getArchivedConversations(userId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load archived conversations';
archivedConversations = [];
} finally {
isLoading = false;
}
},
/**
* Add a new conversation to the list
*/
addConversation(conversation: Conversation) {
conversations = [conversation, ...conversations];
},
/**
* Update a conversation in the list
*/
updateConversation(conversationId: string, updates: Partial<Conversation>) {
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, ...updates } : c
);
},
/**
* Archive a conversation
*/
async archiveConversation(conversationId: string) {
const success = await conversationService.archiveConversation(conversationId);
if (success) {
const conversation = conversations.find((c) => c.id === conversationId);
if (conversation) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = [{ ...conversation, is_archived: true }, ...archivedConversations];
}
}
return success;
},
/**
* Unarchive a conversation
*/
async unarchiveConversation(conversationId: string) {
const success = await conversationService.unarchiveConversation(conversationId);
if (success) {
const conversation = archivedConversations.find((c) => c.id === conversationId);
if (conversation) {
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
conversations = [{ ...conversation, is_archived: false }, ...conversations];
}
}
return success;
},
/**
* Delete a conversation
*/
async deleteConversation(conversationId: string) {
const success = await conversationService.deleteConversation(conversationId);
if (success) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
}
return success;
},
/**
* Clear all data
*/
reset() {
conversations = [];
archivedConversations = [];
error = null;
},
};

View file

@ -0,0 +1,122 @@
/**
* Spaces Store - Manages space list using Svelte 5 runes
*/
import { spaceService } from '$lib/services/space';
import type { Space, SpaceCreate, SpaceUpdate } from '@chat/types';
// State
let spaces = $state<Space[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const spacesStore = {
// Getters
get spaces() {
return spaces;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
/**
* Load spaces for a user
*/
async loadSpaces(userId: string) {
isLoading = true;
error = null;
try {
spaces = await spaceService.getUserSpaces(userId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load spaces';
spaces = [];
} finally {
isLoading = false;
}
},
/**
* Create a new space
*/
async createSpace(space: SpaceCreate): Promise<string | null> {
error = null;
try {
const spaceId = await spaceService.createSpace(space);
if (spaceId) {
// Reload spaces to get the new one with full data
await this.loadSpaces(space.owner_id);
}
return spaceId;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create space';
return null;
}
},
/**
* Update a space
*/
async updateSpace(spaceId: string, updates: SpaceUpdate): Promise<boolean> {
error = null;
try {
const success = await spaceService.updateSpace(spaceId, updates);
if (success) {
spaces = spaces.map((s) => (s.id === spaceId ? { ...s, ...updates } : s));
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update space';
return false;
}
},
/**
* Delete a space
*/
async deleteSpace(spaceId: string): Promise<boolean> {
error = null;
try {
const success = await spaceService.deleteSpace(spaceId);
if (success) {
spaces = spaces.filter((s) => s.id !== spaceId);
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete space';
return false;
}
},
/**
* Leave a space
*/
async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
error = null;
try {
const success = await spaceService.leaveSpace(spaceId, userId);
if (success) {
spaces = spaces.filter((s) => s.id !== spaceId);
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to leave space';
return false;
}
},
/**
* Reset store
*/
reset() {
spaces = [];
error = null;
},
};

View file

@ -0,0 +1,126 @@
/**
* Templates Store - Manages template list using Svelte 5 runes
*/
import { templateService } from '$lib/services/template';
import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
// State
let templates = $state<Template[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const templatesStore = {
// Getters
get templates() {
return templates;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
/**
* Load templates for a user
*/
async loadTemplates(userId: string) {
isLoading = true;
error = null;
try {
templates = await templateService.getTemplates(userId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load templates';
templates = [];
} finally {
isLoading = false;
}
},
/**
* Create a new template
*/
async createTemplate(template: TemplateCreate): Promise<Template | null> {
error = null;
try {
const newTemplate = await templateService.createTemplate(template);
if (newTemplate) {
templates = [...templates, newTemplate].sort((a, b) => a.name.localeCompare(b.name));
}
return newTemplate;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create template';
return null;
}
},
/**
* Update a template
*/
async updateTemplate(templateId: string, updates: TemplateUpdate): Promise<boolean> {
error = null;
try {
const success = await templateService.updateTemplate(templateId, updates);
if (success) {
templates = templates
.map((t) => (t.id === templateId ? { ...t, ...updates } : t))
.sort((a, b) => a.name.localeCompare(b.name));
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update template';
return false;
}
},
/**
* Delete a template
*/
async deleteTemplate(templateId: string): Promise<boolean> {
error = null;
try {
const success = await templateService.deleteTemplate(templateId);
if (success) {
templates = templates.filter((t) => t.id !== templateId);
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete template';
return false;
}
},
/**
* Set a template as default
*/
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
error = null;
try {
const success = await templateService.setDefaultTemplate(templateId, userId);
if (success) {
templates = templates.map((t) => ({
...t,
is_default: t.id === templateId,
}));
}
return success;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set default template';
return false;
}
},
/**
* Reset store
*/
reset() {
templates = [];
error = null;
},
};

View file

@ -0,0 +1,10 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'chat',
defaultVariant: 'ocean',
primaryColor: {
light: '217 91% 60%', // Blue
dark: '217 91% 60%',
},
});

View file

@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 py-12">
<div class="w-full max-w-md">
{@render children()}
</div>
</div>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
let success = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
const result = await authStore.resetPassword(email);
if (result.success) {
success = true;
} else {
error = result.error || 'Fehler beim Zurücksetzen des Passworts';
}
loading = false;
}
</script>
<svelte:head>
<title>Passwort zurücksetzen | ManaChat</title>
</svelte:head>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Passwort zurücksetzen</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
Gib deine E-Mail-Adresse ein, um dein Passwort zurückzusetzen
</p>
</div>
{#if success}
<div class="p-4 bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-green-700 dark:text-green-400 text-center">
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts gesendet.
</p>
<div class="mt-4 text-center">
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
Zurück zur Anmeldung
</a>
</div>
</div>
{:else}
{#if error}
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
E-Mail
</label>
<input
type="email"
id="email"
bind:value={email}
required
disabled={loading}
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="deine@email.de"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="animate-spin w-4 h-4" 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>
Wird gesendet...
</span>
{:else}
Link senden
{/if}
</button>
</form>
<div class="mt-6 text-center">
<a href="/login" class="text-gray-600 dark:text-gray-400 hover:underline">
Zurück zur Anmeldung
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { ChatLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/chat');
// German translations
const translations = {
title: 'Anmelden',
subtitle: 'Melde dich mit deinem Konto an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolgreich!',
orDivider: 'oder',
noAccount: 'Noch kein Konto?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...'
};
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>Anmelden | ManaChat</title>
</svelte:head>
<LoginPage
appName="ManaChat"
logo={ChatLogo}
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
goto={goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#e0f2fe"
darkBackground="#0c1929"
{translations}
/>

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
let success = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (password !== confirmPassword) {
error = 'Die Passwörter stimmen nicht überein';
return;
}
if (password.length < 6) {
error = 'Das Passwort muss mindestens 6 Zeichen lang sein';
return;
}
loading = true;
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
success = true;
} else {
goto('/');
}
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
loading = false;
}
</script>
<svelte:head>
<title>Registrieren | ManaChat</title>
</svelte:head>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">ManaChat</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Erstelle dein Konto</p>
</div>
{#if success}
<div class="p-4 bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-green-700 dark:text-green-400 text-center">
Bitte überprüfe deine E-Mails, um dein Konto zu bestätigen.
</p>
<div class="mt-4 text-center">
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
Zur Anmeldung
</a>
</div>
</div>
{:else}
{#if error}
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
E-Mail
</label>
<input
type="email"
id="email"
bind:value={email}
required
disabled={loading}
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Passwort
</label>
<input
type="password"
id="password"
bind:value={password}
required
disabled={loading}
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Passwort bestätigen
</label>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
required
disabled={loading}
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="animate-spin w-4 h-4" 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>
Wird registriert...
</span>
{:else}
Registrieren
{/if}
</button>
</form>
<div class="mt-6 text-center">
<p class="text-gray-600 dark:text-gray-400">
Bereits ein Konto?
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
Jetzt anmelden
</a>
</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,13 @@
/**
* Protected routes layout server
* Auth checking is now done client-side via Mana Core Auth
*/
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ url }) => {
// Return the current path for client-side redirect logic
return {
pathname: url.pathname,
};
};

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import type { LayoutData } from './$types';
let { children, data }: { children: any; data: LayoutData } = $props();
let isChecking = $state(true);
// Check auth on mount and redirect if not authenticated
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const redirectTo = encodeURIComponent(data.pathname || '/chat');
goto(`/login?redirectTo=${redirectTo}`);
return;
}
isChecking = false;
});
async function handleSignOut() {
await authStore.signOut();
goto('/login');
}
</script>
{#if isChecking}
<!-- Loading state while checking auth -->
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
</div>
{:else}
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Top Navigation -->
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
ManaChat
</a>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname === '/' || $page.url.pathname.startsWith('/chat')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Chat
</a>
<a
href="/templates"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/templates')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Templates
</a>
<a
href="/spaces"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/spaces')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Spaces
</a>
<a
href="/documents"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/documents')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Dokumente
</a>
<a
href="/archive"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/archive')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Archiv
</a>
</div>
</div>
<div class="flex items-center gap-4">
{#if authStore.user}
<span class="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{authStore.user.email}
</span>
{/if}
<a
href="/profile"
class="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Profil"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</a>
<button
onclick={handleSignOut}
class="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Abmelden
</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
{@render children()}
</main>
</div>
{/if}

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { Conversation } from '@chat/types';
let conversations = $state<Conversation[]>([]);
let isLoading = $state(true);
onMount(async () => {
if (authStore.user) {
await conversationsStore.loadArchivedConversations(authStore.user.id);
conversations = conversationsStore.archivedConversations;
}
isLoading = false;
});
// Keep conversations in sync with store
$effect(() => {
conversations = conversationsStore.archivedConversations;
});
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function handleConversationClick(id: string) {
goto(`/chat/${id}`);
}
async function handleUnarchive(id: string) {
await conversationsStore.unarchiveConversation(id);
}
async function handleDelete(id: string) {
if (confirm('Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
await conversationsStore.deleteConversation(id);
}
}
</script>
<svelte:head>
<title>Archiv | ManaChat</title>
</svelte:head>
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-4xl mx-auto px-4">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Archiv</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Deine archivierten Konversationen.
</p>
</div>
<!-- Loading State -->
{#if isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if conversations.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg
class="w-16 h-16 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine archivierten Konversationen</h3>
<p class="text-gray-500 dark:text-gray-400">
Archivierte Gespräche erscheinen hier.
</p>
</div>
{:else}
<!-- Conversation List -->
<div class="space-y-3">
{#each conversations as conv (conv.id)}
<div
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
shadow-sm hover:shadow-md transition-all overflow-hidden"
>
<button
onclick={() => handleConversationClick(conv.id)}
class="w-full p-4 text-left"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<h3 class="font-semibold text-gray-900 dark:text-white">
{conv.title || 'Unbenannte Konversation'}
</h3>
</div>
<span class="text-xs text-gray-500">{formatDate(conv.updated_at)}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
{conv.conversation_mode === 'free' ? 'Freier Modus' :
conv.conversation_mode === 'guided' ? 'Geführter Modus' : 'Vorlagen-Modus'}
</span>
</div>
</button>
<!-- Actions -->
<div class="flex justify-end gap-2 px-4 py-2 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<button
onclick={() => handleUnarchive(conv.id)}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20
rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
Wiederherstellen
</button>
<button
onclick={() => handleDelete(conv.id)}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Error Message -->
{#if conversationsStore.error}
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
{conversationsStore.error}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { onMount } from 'svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import ConversationList from '$lib/components/chat/ConversationList.svelte';
let { children }: { children: any } = $props();
let showSidebar = $state(true);
onMount(async () => {
if (authStore.user) {
await conversationsStore.loadConversations(authStore.user.id);
}
});
function toggleSidebar() {
showSidebar = !showSidebar;
}
</script>
<div class="flex h-[calc(100vh-4rem)]">
<!-- Sidebar Toggle (mobile) -->
<button
onclick={toggleSidebar}
class="fixed bottom-4 left-4 z-50 p-3 bg-blue-600 text-white rounded-full shadow-lg
sm:hidden hover:bg-blue-700 transition-colors"
aria-label={showSidebar ? 'Seitenleiste schließen' : 'Seitenleiste öffnen'}
>
{#if showSidebar}
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</button>
<!-- Sidebar -->
<aside
class="w-72 flex-shrink-0 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700
transition-transform duration-200 ease-in-out
fixed sm:static inset-y-0 left-0 z-40 top-16
{showSidebar ? 'translate-x-0' : '-translate-x-full sm:translate-x-0 sm:w-0 sm:border-0'}"
>
<ConversationList
conversations={conversationsStore.conversations}
isLoading={conversationsStore.isLoading}
/>
</aside>
<!-- Mobile Overlay -->
{#if showSidebar}
<button
class="fixed inset-0 bg-black/50 z-30 sm:hidden"
onclick={toggleSidebar}
aria-label="Seitenleiste schließen"
></button>
{/if}
<!-- Main Content -->
<main class="flex-1 overflow-hidden">
{@render children()}
</main>
</div>

View file

@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { chatService } from '$lib/services/chat';
import { conversationService } from '$lib/services/conversation';
import { templateService } from '$lib/services/template';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import { theme } from '$lib/stores/theme';
import type { AIModel, Message, Template } from '@chat/types';
let models = $state<AIModel[]>([]);
let templates = $state<Template[]>([]);
let selectedModelId = $state('');
let selectedTemplateId = $state('');
let documentMode = $state(false);
let messages = $state<Message[]>([]);
let isLoading = $state(true);
let isSending = $state(false);
let error = $state<string | null>(null);
// Get selected template
const selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId));
onMount(async () => {
models = await chatService.getModels();
if (models.length > 0) {
selectedModelId = models[0].id;
}
// Load user templates
if (authStore.user) {
templates = await templateService.getTemplates(authStore.user.id);
}
isLoading = false;
});
async function handleSend(text: string) {
if (!authStore.user || !selectedModelId) return;
isSending = true;
error = null;
// Add optimistic user message
const tempUserMessage: Message = {
id: `temp-${Date.now()}`,
conversation_id: '',
sender: 'user',
message_text: text,
created_at: new Date().toISOString(),
};
messages = [...messages, tempUserMessage];
try {
// Determine mode and model based on template
const mode = selectedTemplate ? 'template' : 'free';
const modelToUse = selectedTemplate?.model_id || selectedModelId;
const docMode = selectedTemplate?.document_mode || documentMode;
// Create new conversation
const conversationId = await conversationService.createConversation(
authStore.user.id,
modelToUse,
mode as 'free' | 'guided' | 'template',
selectedTemplate?.id,
docMode
);
if (!conversationId) {
throw new Error('Konversation konnte nicht erstellt werden');
}
// Send message and get response
const result = await conversationService.sendMessageAndGetResponse(
conversationId,
text,
modelToUse
);
// Reload conversations list
await conversationsStore.loadConversations(authStore.user.id);
// Navigate to the new conversation
goto(`/chat/${conversationId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Konversation';
messages = [];
} finally {
isSending = false;
}
}
function handleModelSelect(modelId: string) {
selectedModelId = modelId;
}
function handleTemplateSelect(e: Event) {
const target = e.target as HTMLSelectElement;
selectedTemplateId = target.value;
// If template has a model, update selected model
const template = templates.find((t) => t.id === target.value);
if (template?.model_id) {
selectedModelId = template.model_id;
}
// If template has document mode, enable it
if (template?.document_mode) {
documentMode = true;
}
}
function toggleDocumentMode() {
documentMode = !documentMode;
}
function toggleTheme() {
theme.toggleMode();
}
</script>
<svelte:head>
<title>Chat | ManaChat</title>
</svelte:head>
<div class="flex flex-col h-full">
<!-- Chat Header -->
<header class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
<div class="flex items-center justify-between max-w-4xl mx-auto">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Neuer Chat</h2>
<!-- Model Selector -->
<ModelSelector
{models}
{selectedModelId}
onSelect={handleModelSelect}
disabled={isSending}
/>
<!-- Template Selector -->
{#if templates.length > 0}
<select
onchange={handleTemplateSelect}
value={selectedTemplateId}
disabled={isSending}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-white
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50"
>
<option value="">Ohne Vorlage</option>
{#each templates as template}
<option value={template.id}>
{template.name}
{template.is_default ? ' (Standard)' : ''}
</option>
{/each}
</select>
{/if}
<!-- Document Mode Toggle -->
<button
onclick={toggleDocumentMode}
disabled={isSending}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg transition-colors
{documentMode
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-300 dark:border-blue-700'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-600'}
hover:bg-opacity-80 disabled:opacity-50"
title="Dokumentmodus aktivieren"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Dokument
</button>
</div>
<div class="flex items-center gap-2">
<button
onclick={toggleTheme}
class="p-2 text-gray-700 dark:text-gray-300
bg-gray-100 dark:bg-gray-800 rounded-lg
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Theme wechseln"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</button>
</div>
</div>
</header>
<!-- Messages Area -->
<main class="flex-1 overflow-hidden bg-white dark:bg-gray-900">
<div class="h-full max-w-4xl mx-auto flex flex-col">
<MessageList {messages} isTyping={isSending} />
</div>
</main>
<!-- Input Area -->
<ChatInput onSend={handleSend} disabled={isSending || isLoading} />
<!-- Error Message -->
{#if error}
<div
class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-red-500 text-white rounded-lg shadow-lg"
>
{error}
</div>
{/if}
</div>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { conversationService } from '$lib/services/conversation';
import { chatService } from '$lib/services/chat';
import { documentService } from '$lib/services/document';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import type { Conversation, Message, AIModel, Document } from '@chat/types';
let conversation = $state<Conversation | null>(null);
let messages = $state<Message[]>([]);
let models = $state<AIModel[]>([]);
let selectedModelId = $state('');
let isLoading = $state(true);
let isSending = $state(false);
let error = $state<string | null>(null);
// Document mode state
let document = $state<Document | null>(null);
let documentContent = $state('');
let documentVersions = $state<Document[]>([]);
let isSavingDocument = $state(false);
let showVersionsModal = $state(false);
let showDocumentPanel = $state(true);
const conversationId = $derived($page.params.id ?? '');
const isDocumentMode = $derived(conversation?.document_mode ?? false);
onMount(async () => {
await loadData();
});
async function loadData() {
isLoading = true;
error = null;
try {
// Load models
models = await chatService.getModels();
// Load conversation
conversation = await conversationService.getConversation(conversationId);
if (!conversation) {
error = 'Konversation nicht gefunden';
return;
}
// Set model from conversation
selectedModelId = conversation.model_id;
// Load messages
messages = await conversationService.getMessages(conversationId);
// Load document if in document mode
if (conversation.document_mode) {
document = await documentService.getLatestDocument(conversationId);
documentContent = document?.content ?? '';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden';
} finally {
isLoading = false;
}
}
async function saveDocument() {
if (!documentContent.trim()) return;
isSavingDocument = true;
try {
if (document) {
// Create new version
document = await documentService.createDocumentVersion(conversationId, documentContent);
} else {
// Create first document
document = await documentService.createDocument(conversationId, documentContent);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Speichern';
} finally {
isSavingDocument = false;
}
}
async function loadVersions() {
documentVersions = await documentService.getAllDocumentVersions(conversationId);
showVersionsModal = true;
}
function restoreVersion(version: Document) {
documentContent = version.content;
showVersionsModal = false;
}
function toggleDocumentPanel() {
showDocumentPanel = !showDocumentPanel;
}
async function handleSend(text: string) {
if (!conversation || !selectedModelId) return;
isSending = true;
error = null;
// Optimistic update - add user message
const tempUserMessage: Message = {
id: `temp-user-${Date.now()}`,
conversation_id: conversationId,
sender: 'user',
message_text: text,
created_at: new Date().toISOString(),
};
messages = [...messages, tempUserMessage];
try {
const result = await conversationService.sendMessageAndGetResponse(
conversationId,
text,
selectedModelId
);
// Update messages with real data
messages = await conversationService.getMessages(conversationId);
// Update conversation title if generated
if (result.title && conversation) {
conversation = { ...conversation, title: result.title };
conversationsStore.updateConversation(conversationId, { title: result.title });
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Senden';
// Remove optimistic message on error
messages = messages.filter((m) => m.id !== tempUserMessage.id);
} finally {
isSending = false;
}
}
function handleModelSelect(modelId: string) {
selectedModelId = modelId;
}
async function handleArchive() {
if (!conversation) return;
const success = await conversationsStore.archiveConversation(conversationId);
if (success) {
goto('/chat');
}
}
async function handleDelete() {
if (!conversation) return;
if (confirm('Möchtest du diese Konversation wirklich löschen?')) {
const success = await conversationsStore.deleteConversation(conversationId);
if (success) {
goto('/chat');
}
}
}
function toggleTheme() {
theme.toggleMode();
}
</script>
<svelte:head>
<title>{conversation?.title || 'Chat'} | ManaChat</title>
</svelte:head>
{#if isLoading}
<div class="flex items-center justify-center h-full">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if error && !conversation}
<div class="flex flex-col items-center justify-center h-full text-center p-4">
<p class="text-red-500 mb-4">{error}</p>
<a href="/chat" class="text-blue-600 hover:underline">Zurück zum Chat</a>
</div>
{:else}
<div class="flex flex-col h-full">
<!-- Chat Header -->
<header class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
<div class="flex items-center justify-between max-w-4xl mx-auto">
<div class="flex items-center gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-xs">
{conversation?.title || 'Chat'}
</h2>
<ModelSelector
{models}
{selectedModelId}
onSelect={handleModelSelect}
disabled={isSending}
/>
</div>
<div class="flex items-center gap-2">
{#if isDocumentMode}
<button
onclick={toggleDocumentPanel}
class="p-2 transition-colors rounded-lg
{showDocumentPanel
? 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30'
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'}"
aria-label="Dokument-Panel"
title="Dokument-Panel ein/ausblenden"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
{/if}
<button
onclick={handleArchive}
class="p-2 text-gray-700 dark:text-gray-300
bg-gray-100 dark:bg-gray-800 rounded-lg
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Archivieren"
title="Archivieren"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
<button
onclick={handleDelete}
class="p-2 text-red-600 dark:text-red-400
bg-gray-100 dark:bg-gray-800 rounded-lg
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
aria-label="Löschen"
title="Löschen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button
onclick={toggleTheme}
class="p-2 text-gray-700 dark:text-gray-300
bg-gray-100 dark:bg-gray-800 rounded-lg
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Theme wechseln"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
<div class="flex-1 flex flex-col overflow-hidden {isDocumentMode && showDocumentPanel ? 'lg:w-1/2' : 'w-full'}">
<!-- Messages Area -->
<main class="flex-1 overflow-hidden bg-white dark:bg-gray-900">
<div class="h-full max-w-4xl mx-auto flex flex-col">
<MessageList {messages} isTyping={isSending} />
</div>
</main>
<!-- Input Area -->
<ChatInput onSend={handleSend} disabled={isSending} />
</div>
<!-- Document Panel -->
{#if isDocumentMode && showDocumentPanel}
<div class="hidden lg:flex lg:w-1/2 flex-col border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<!-- Document Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">Dokument</span>
{#if document}
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
v{document.version}
</span>
{/if}
</div>
<div class="flex items-center gap-2">
<button
onclick={loadVersions}
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white
hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Versionen anzeigen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
onclick={saveDocument}
disabled={isSavingDocument || !documentContent.trim()}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white
bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
rounded-lg transition-colors"
>
{#if isSavingDocument}
<div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
{/if}
Speichern
</button>
</div>
</div>
<!-- Document Editor -->
<div class="flex-1 p-4 overflow-auto">
<textarea
bind:value={documentContent}
placeholder="Beginne hier mit dem Schreiben deines Dokuments...
Du kannst Markdown verwenden:
# Überschrift
## Unterüberschrift
- Aufzählung
**Fett** und *Kursiv*"
class="w-full h-full min-h-[300px] p-4 text-sm font-mono
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white
border border-gray-200 dark:border-gray-700 rounded-lg
focus:ring-2 focus:ring-blue-500 focus:border-transparent
resize-none"
></textarea>
</div>
</div>
{/if}
</div>
<!-- Error Message -->
{#if error}
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-red-500 text-white rounded-lg shadow-lg">
{error}
</div>
{/if}
</div>
<!-- Versions Modal -->
{#if showVersionsModal}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Dokumentversionen</h3>
<button
onclick={() => showVersionsModal = false}
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Schließen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-auto p-4">
{#if documentVersions.length === 0}
<p class="text-center text-gray-500 dark:text-gray-400 py-8">Keine Versionen vorhanden</p>
{:else}
<div class="space-y-2">
{#each documentVersions as version (version.id)}
<button
onclick={() => restoreVersion(version)}
class="w-full p-3 text-left rounded-lg border border-gray-200 dark:border-gray-700
hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors
{version.id === document?.id ? 'ring-2 ring-blue-500' : ''}"
>
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-gray-900 dark:text-white">
Version {version.version}
{version.id === document?.id ? ' (aktuell)' : ''}
</span>
<span class="text-xs text-gray-500">
{new Date(version.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{version.content.substring(0, 100)}...
</p>
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { documentService } from '$lib/services/document';
import type { DocumentWithConversation } from '@chat/types';
let documents = $state<DocumentWithConversation[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
if (authStore.user) {
await loadDocuments();
}
});
async function loadDocuments() {
isLoading = true;
error = null;
try {
documents = await documentService.getUserDocuments(authStore.user!.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Dokumente';
} finally {
isLoading = false;
}
}
function extractTitle(content: string): string {
// Look for markdown heading level 1 at the start
const titleMatch = content.match(/^#\s+(.+)$/m);
if (titleMatch && titleMatch[1]) {
return titleMatch[1].trim();
}
// Alternative: Look for heading level 2
const subtitleMatch = content.match(/^##\s+(.+)$/m);
if (subtitleMatch && subtitleMatch[1]) {
return subtitleMatch[1].trim();
}
// If no heading found, take first words
const firstLine = content.split('\n')[0].trim();
if (firstLine.length > 0) {
return firstLine.length > 40 ? `${firstLine.substring(0, 37)}...` : firstLine;
}
return 'Dokument ohne Titel';
}
function getPreview(content: string): string {
// Remove the first heading if present
let preview = content.replace(/^#\s+.+$/m, '').trim();
// Take first 200 characters
if (preview.length > 200) {
preview = preview.substring(0, 200) + '...';
}
return preview;
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function navigateToConversation(conversationId: string) {
goto(`/chat/${conversationId}`);
}
</script>
<svelte:head>
<title>Dokumente | ManaChat</title>
</svelte:head>
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-6xl mx-auto px-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dokumente</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Alle Dokumente aus deinen Konversationen im Dokumentmodus.
</p>
</div>
<button
onclick={loadDocuments}
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200
hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Aktualisieren"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
<!-- Loading State -->
{#if isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if documents.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg
class="w-16 h-16 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Dokumente gefunden</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
Erstelle ein neues Dokument in einer Konversation mit aktiviertem Dokumentmodus.
</p>
</div>
{:else}
<!-- Documents Grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each documents as doc (doc.id)}
<button
onclick={() => navigateToConversation(doc.conversation_id)}
class="text-left p-0 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
shadow-sm hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all overflow-hidden"
>
<!-- Header -->
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2">
{extractTitle(doc.content)}
</h3>
<div class="flex items-center justify-between text-xs text-gray-500">
<span class="truncate">{doc.conversation_title}</span>
<div class="flex items-center gap-2 flex-shrink-0">
<span>{formatDate(doc.updated_at)}</span>
<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-medium">
v{doc.version}
</span>
</div>
</div>
</div>
<!-- Preview -->
<div class="p-4 h-32 overflow-hidden">
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-5">
{getPreview(doc.content)}
</p>
</div>
</button>
{/each}
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
{error}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
function handleSignOut() {
authStore.signOut();
goto('/login');
}
function toggleTheme() {
theme.toggleMode();
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Profil | ManaChat</title>
</svelte:head>
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-2xl mx-auto px-4">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Profil</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Verwalte dein Konto und deine Einstellungen.
</p>
</div>
<!-- Profile Card -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden mb-6">
<div class="p-6">
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{authStore.user?.email || 'Benutzer'}
</h2>
<p class="text-sm text-gray-500">
Mitglied seit {formatDate(authStore.user?.created_at)}
</p>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700">
<div>
<p class="font-medium text-gray-900 dark:text-white">E-Mail</p>
<p class="text-sm text-gray-500">{authStore.user?.email || '-'}</p>
</div>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700">
<div>
<p class="font-medium text-gray-900 dark:text-white">Benutzer-ID</p>
<p class="text-sm text-gray-500 font-mono">{authStore.user?.id || '-'}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Card -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden mb-6">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Einstellungen</h3>
<div class="space-y-4">
<!-- Theme Toggle -->
<div class="flex items-center justify-between py-3">
<div>
<p class="font-medium text-gray-900 dark:text-white">Dunkler Modus</p>
<p class="text-sm text-gray-500">Aktiviere den dunklen Modus für die App</p>
</div>
<button
onclick={toggleTheme}
class="relative w-12 h-6 rounded-full transition-colors
{theme.mode === 'dark' ? 'bg-blue-600' : 'bg-gray-300'}"
role="switch"
aria-checked={theme.mode === 'dark'}
aria-label="Dunkler Modus umschalten"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform
{theme.mode === 'dark' ? 'translate-x-6' : 'translate-x-0'}"
></span>
</button>
</div>
</div>
</div>
</div>
<!-- Sign Out -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6">
<button
onclick={handleSignOut}
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 dark:bg-red-900/20
text-red-600 dark:text-red-400 rounded-lg font-medium
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Abmelden
</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import SpaceCard from '$lib/components/spaces/SpaceCard.svelte';
import SpaceForm from '$lib/components/spaces/SpaceForm.svelte';
import type { Space } from '@chat/types';
let showForm = $state(false);
let editingSpace = $state<Space | undefined>(undefined);
onMount(async () => {
if (authStore.user) {
await spacesStore.loadSpaces(authStore.user.id);
}
});
function handleCreateNew() {
editingSpace = undefined;
showForm = true;
}
function handleSelect(id: string) {
// Navigate to space - load conversations filtered by this space
goto(`/spaces/${id}`);
}
function handleEdit(id: string) {
const space = spacesStore.spaces.find((s) => s.id === id);
if (space) {
editingSpace = space;
showForm = true;
}
}
async function handleDelete(id: string) {
if (confirm('Möchtest du diesen Space wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
await spacesStore.deleteSpace(id);
}
}
async function handleLeave(id: string) {
if (!authStore.user) return;
if (confirm('Möchtest du diesen Space wirklich verlassen?')) {
await spacesStore.leaveSpace(id, authStore.user.id);
}
}
async function handleSubmit(data: { name: string; description?: string }) {
if (!authStore.user) return;
if (editingSpace) {
// Update existing space
await spacesStore.updateSpace(editingSpace.id, data);
} else {
// Create new space
await spacesStore.createSpace({
name: data.name,
description: data.description,
owner_id: authStore.user.id,
});
}
showForm = false;
editingSpace = undefined;
}
function handleCancel() {
showForm = false;
editingSpace = undefined;
}
function isOwner(space: Space): boolean {
return space.owner_id === authStore.user?.id;
}
</script>
<svelte:head>
<title>Spaces | ManaChat</title>
</svelte:head>
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-4xl mx-auto px-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Spaces</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Organisiere deine Konversationen in kollaborativen Arbeitsbereichen.
</p>
</div>
<button
onclick={handleCreateNew}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuen Space erstellen
</button>
</div>
<!-- Loading State -->
{#if spacesStore.isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if spacesStore.spaces.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg
class="w-16 h-16 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Spaces gefunden</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Erstelle einen neuen Space oder frage nach einer Einladung
</p>
<button
onclick={handleCreateNew}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Ersten Space erstellen
</button>
</div>
{:else}
<!-- Spaces Grid -->
<div class="grid gap-4 sm:grid-cols-2">
{#each spacesStore.spaces as space (space.id)}
<SpaceCard
{space}
isOwner={isOwner(space)}
onSelect={handleSelect}
onEdit={handleEdit}
onDelete={handleDelete}
onLeave={handleLeave}
/>
{/each}
</div>
{/if}
<!-- Error Message -->
{#if spacesStore.error}
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
{spacesStore.error}
</div>
{/if}
</div>
</div>
<!-- Form Modal -->
{#if showForm}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl shadow-xl">
<SpaceForm space={editingSpace} onSubmit={handleSubmit} onCancel={handleCancel} />
</div>
</div>
{/if}

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { spaceService } from '$lib/services/space';
import { conversationService } from '$lib/services/conversation';
import { chatService } from '$lib/services/chat';
import type { Space, Conversation, AIModel } from '@chat/types';
const spaceId = $derived($page.params.id ?? '');
let space = $state<Space | null>(null);
let conversations = $state<Conversation[]>([]);
let models = $state<AIModel[]>([]);
let selectedModelId = $state('');
let isLoading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
await loadData();
});
async function loadData() {
isLoading = true;
error = null;
try {
// Load space details
space = await spaceService.getSpace(spaceId);
if (!space) {
error = 'Space nicht gefunden';
return;
}
// Load conversations in this space
if (authStore.user) {
conversations = await conversationService.getConversations(authStore.user.id, spaceId);
}
// Load models
models = await chatService.getModels();
if (models.length > 0) {
selectedModelId = models[0].id;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden';
} finally {
isLoading = false;
}
}
async function handleNewChat() {
if (!authStore.user || !selectedModelId) return;
const conversationId = await conversationService.createConversation(
authStore.user.id,
selectedModelId,
'free',
undefined,
false,
spaceId
);
if (conversationId) {
goto(`/chat/${conversationId}`);
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<svelte:head>
<title>{space?.name || 'Space'} | ManaChat</title>
</svelte:head>
{#if isLoading}
<div class="flex items-center justify-center h-[calc(100vh-4rem)]">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if error}
<div class="flex flex-col items-center justify-center h-[calc(100vh-4rem)] text-center p-4">
<p class="text-red-500 mb-4">{error}</p>
<a href="/spaces" class="text-blue-600 hover:underline">Zurück zu Spaces</a>
</div>
{:else if space}
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-4xl mx-auto px-4">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-2">
<a
href="/spaces"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Zurück zu Spaces"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{space.name}</h1>
</div>
{#if space.description}
<p class="text-sm text-gray-600 dark:text-gray-400">{space.description}</p>
{/if}
</div>
<!-- New Chat Section -->
<div class="mb-8 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Neuen Chat starten</h2>
<div class="flex items-center gap-3">
<select
bind:value={selectedModelId}
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{#each models as model}
<option value={model.id}>{model.name}</option>
{/each}
</select>
<button
onclick={handleNewChat}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors whitespace-nowrap"
>
Chat starten
</button>
</div>
</div>
<!-- Conversations List -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Konversationen in diesem Space
</h2>
{#if conversations.length === 0}
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p class="text-gray-500 dark:text-gray-400">
Noch keine Konversationen in diesem Space.
</p>
</div>
{:else}
<div class="space-y-3">
{#each conversations as conv (conv.id)}
<a
href="/chat/{conv.id}"
class="block p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
hover:border-blue-300 dark:hover:border-blue-600 transition-colors"
>
<div class="flex items-center justify-between">
<h3 class="font-medium text-gray-900 dark:text-white">
{conv.title || 'Neue Konversation'}
</h3>
<span class="text-xs text-gray-500">{formatDate(conv.updated_at)}</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { templatesStore } from '$lib/stores/templates.svelte';
import { conversationService } from '$lib/services/conversation';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import TemplateCard from '$lib/components/templates/TemplateCard.svelte';
import TemplateForm from '$lib/components/templates/TemplateForm.svelte';
import type { Template } from '@chat/types';
let showForm = $state(false);
let editingTemplate = $state<Template | undefined>(undefined);
onMount(async () => {
if (authStore.user) {
await templatesStore.loadTemplates(authStore.user.id);
}
});
function handleCreateNew() {
editingTemplate = undefined;
showForm = true;
}
function handleEdit(id: string) {
const template = templatesStore.templates.find((t) => t.id === id);
if (template) {
editingTemplate = template;
showForm = true;
}
}
async function handleDelete(id: string) {
if (confirm('Möchtest du diese Vorlage wirklich löschen?')) {
await templatesStore.deleteTemplate(id);
}
}
async function handleSetDefault(id: string) {
if (authStore.user) {
await templatesStore.setDefaultTemplate(id, authStore.user.id);
}
}
async function handleUse(id: string) {
const template = templatesStore.templates.find((t) => t.id === id);
if (!template || !authStore.user) return;
// Create a new conversation with this template
const conversationId = await conversationService.createConversation(
authStore.user.id,
template.model_id || '550e8400-e29b-41d4-a716-446655440004', // Default to GPT-4o-Mini
'template',
template.id,
template.document_mode
);
if (conversationId) {
await conversationsStore.loadConversations(authStore.user.id);
goto(`/chat/${conversationId}`);
}
}
async function handleSubmit(data: Partial<Template>) {
if (!authStore.user) return;
if (data.id) {
// Update existing template
await templatesStore.updateTemplate(data.id, {
name: data.name,
description: data.description,
system_prompt: data.system_prompt,
initial_question: data.initial_question,
color: data.color,
model_id: data.model_id,
document_mode: data.document_mode,
});
} else {
// Create new template
await templatesStore.createTemplate({
user_id: authStore.user.id,
name: data.name!,
description: data.description ?? null,
system_prompt: data.system_prompt!,
initial_question: data.initial_question ?? null,
color: data.color!,
model_id: data.model_id ?? null,
is_default: false,
document_mode: data.document_mode ?? false,
});
}
showForm = false;
editingTemplate = undefined;
}
function handleCancel() {
showForm = false;
editingTemplate = undefined;
}
</script>
<svelte:head>
<title>Vorlagen | ManaChat</title>
</svelte:head>
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-4xl mx-auto px-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Vorlagen</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Erstelle Vorlagen mit benutzerdefinierten System-Prompts für verschiedene KI-Verhaltensweisen.
</p>
</div>
<button
onclick={handleCreateNew}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neue Vorlage
</button>
</div>
<!-- Loading State -->
{#if templatesStore.isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
</div>
{:else if templatesStore.templates.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg
class="w-16 h-16 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Vorlagen vorhanden</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Erstelle deine erste Vorlage, um loszulegen
</p>
<button
onclick={handleCreateNew}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Erste Vorlage erstellen
</button>
</div>
{:else}
<!-- Templates Grid -->
<div class="grid gap-4 sm:grid-cols-2">
{#each templatesStore.templates as template (template.id)}
<TemplateCard
{template}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
onSetDefault={handleSetDefault}
/>
{/each}
</div>
{/if}
<!-- Error Message -->
{#if templatesStore.error}
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
{templatesStore.error}
</div>
{/if}
</div>
</div>
<!-- Form Modal -->
{#if showForm}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl shadow-xl">
<TemplateForm template={editingTemplate} onSubmit={handleSubmit} onCancel={handleCancel} />
</div>
</div>
{/if}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
let { children } = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
</script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
{@render children()}
</div>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
onMount(async () => {
await authStore.initialize();
if (authStore.isAuthenticated) {
goto('/chat', { replaceState: true });
} else {
goto('/login', { replaceState: true });
}
});
</script>
<svelte:head>
<title>ManaChat - AI Chat Assistant</title>
<meta name="description" content="Chat with AI models - GPT-4o, GPT-4o-Mini, and more" />
</svelte:head>
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<div class="animate-spin w-10 h-10 border-4 border-blue-500 border-r-transparent rounded-full mx-auto"></div>
<p class="mt-4 text-gray-600 dark:text-gray-400">Wird geladen...</p>
</div>
</div>

View file

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

View file

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

View file

@ -0,0 +1,25 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: [
'marked',
'@manacore/shared-theme',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui'
]
},
optimizeDeps: {
exclude: [
'@manacore/shared-theme',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui'
]
}
});