diff --git a/chat/apps/web/.env.example b/chat/apps/web/.env.example new file mode 100644 index 000000000..407c57ecc --- /dev/null +++ b/chat/apps/web/.env.example @@ -0,0 +1,6 @@ +# Supabase Configuration (same as mobile app) +PUBLIC_SUPABASE_URL=https://your-project.supabase.co +PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key + +# Chat Backend API +PUBLIC_BACKEND_URL=http://localhost:3001 diff --git a/chat/apps/web/package.json b/chat/apps/web/package.json index 7e27dd20e..1a8bc4e3e 100644 --- a/chat/apps/web/package.json +++ b/chat/apps/web/package.json @@ -27,6 +27,7 @@ "vite": "^7.1.7" }, "dependencies": { + "@chat/types": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-i18n": "workspace:*", @@ -36,6 +37,8 @@ "@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" } diff --git a/chat/apps/web/postcss.config.js b/chat/apps/web/postcss.config.js new file mode 100644 index 000000000..85b958cb5 --- /dev/null +++ b/chat/apps/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {} + } +}; diff --git a/chat/apps/web/src/app.css b/chat/apps/web/src/app.css index d4b507858..d75fc9594 100644 --- a/chat/apps/web/src/app.css +++ b/chat/apps/web/src/app.css @@ -1 +1,8 @@ -@import 'tailwindcss'; +@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"; diff --git a/chat/apps/web/src/app.d.ts b/chat/apps/web/src/app.d.ts new file mode 100644 index 000000000..422863fe8 --- /dev/null +++ b/chat/apps/web/src/app.d.ts @@ -0,0 +1,21 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +import type { SupabaseClient, Session, User } from '@supabase/supabase-js'; + +declare global { + namespace App { + // interface Error {} + interface Locals { + supabase: SupabaseClient; + safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; + } + interface PageData { + session: Session | null; + user: User | null; + } + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/chat/apps/web/src/hooks.server.ts b/chat/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..41e1b0c29 --- /dev/null +++ b/chat/apps/web/src/hooks.server.ts @@ -0,0 +1,41 @@ +/** + * Server Hooks for SvelteKit + * Handles Supabase session management + */ + +import type { Handle } from '@sveltejs/kit'; +import { createSupabaseServerClient } from '$lib/services/supabase'; + +export const handle: Handle = async ({ event, resolve }) => { + // Create Supabase client for this request + event.locals.supabase = createSupabaseServerClient(event.cookies); + + // Get session + event.locals.safeGetSession = async () => { + const { + data: { session }, + } = await event.locals.supabase.auth.getSession(); + + if (!session) { + return { session: null, user: null }; + } + + // Validate user (not just reading from cookies) + const { + data: { user }, + error, + } = await event.locals.supabase.auth.getUser(); + + if (error) { + return { session: null, user: null }; + } + + return { session, user }; + }; + + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === 'content-range' || name === 'x-supabase-api-version'; + }, + }); +}; diff --git a/chat/apps/web/src/lib/components/chat/ChatInput.svelte b/chat/apps/web/src/lib/components/chat/ChatInput.svelte new file mode 100644 index 000000000..d4e7711fa --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/ChatInput.svelte @@ -0,0 +1,82 @@ + + +
+
+
+ +
+ +
+

+ Enter zum Senden, Shift+Enter für neue Zeile +

+
diff --git a/chat/apps/web/src/lib/components/chat/ConversationList.svelte b/chat/apps/web/src/lib/components/chat/ConversationList.svelte new file mode 100644 index 000000000..cddc5474a --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/ConversationList.svelte @@ -0,0 +1,87 @@ + + +
+ +
+ + + + + Neuer Chat + +
+ + +
+ {#if isLoading} +
+
+
+ {:else if conversations.length === 0} +
+

Keine Konversationen

+

Starte einen neuen Chat

+
+ {:else} +
+ {#each conversations as conv (conv.id)} + {@const isActive = $page.params.id === conv.id} + +
+ + {truncateTitle(conv.title || 'Neue Konversation')} + + + {formatDate(conv.updated_at || conv.created_at)} + +
+
+ {/each} +
+ {/if} +
+
diff --git a/chat/apps/web/src/lib/components/chat/MessageBubble.svelte b/chat/apps/web/src/lib/components/chat/MessageBubble.svelte new file mode 100644 index 000000000..f51d5322f --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/MessageBubble.svelte @@ -0,0 +1,52 @@ + + +
+
+ {#if isUser} +

{message.message_text}

+ {:else} +
+ {@html htmlContent} +
+ {/if} +
+ {formattedTime} +
+
+
diff --git a/chat/apps/web/src/lib/components/chat/MessageList.svelte b/chat/apps/web/src/lib/components/chat/MessageList.svelte new file mode 100644 index 000000000..4ac97ea28 --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/MessageList.svelte @@ -0,0 +1,64 @@ + + +
+ {#if messages.length === 0} +
+ + + +

Keine Nachrichten

+

Starte eine Konversation!

+
+ {:else} + {#each messages as message (message.id)} + + {/each} + {#if isTyping} + + {/if} + {/if} +
diff --git a/chat/apps/web/src/lib/components/chat/ModelSelector.svelte b/chat/apps/web/src/lib/components/chat/ModelSelector.svelte new file mode 100644 index 000000000..64de1d1e8 --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/ModelSelector.svelte @@ -0,0 +1,55 @@ + + +
+ +
+ + + +
+
diff --git a/chat/apps/web/src/lib/components/chat/TypingIndicator.svelte b/chat/apps/web/src/lib/components/chat/TypingIndicator.svelte new file mode 100644 index 000000000..fd0f43dae --- /dev/null +++ b/chat/apps/web/src/lib/components/chat/TypingIndicator.svelte @@ -0,0 +1,41 @@ + + +
+
+
+
+
+
+
+
+
+ + diff --git a/chat/apps/web/src/lib/components/spaces/SpaceCard.svelte b/chat/apps/web/src/lib/components/spaces/SpaceCard.svelte new file mode 100644 index 000000000..084ac4a71 --- /dev/null +++ b/chat/apps/web/src/lib/components/spaces/SpaceCard.svelte @@ -0,0 +1,162 @@ + + + (showMenu = false)} /> + +
onSelect(space.id)} + onkeydown={(e) => e.key === 'Enter' && onSelect(space.id)} + role="button" + tabindex="0" +> +
+
+
+
+ + + +

+ {space.name} +

+ {#if isOwner} + + Besitzer + + {/if} +
+ + {#if space.description} +

+ {space.description} +

+ {/if} + +

+ Erstellt: {formatDate(space.created_at)} +

+
+ + +
+ + + {#if showMenu} +
e.stopPropagation()} + onkeydown={() => {}} + role="menu" + tabindex="-1" + > + {#if isOwner} + + + {:else} + + {/if} +
+ {/if} +
+
+
+
diff --git a/chat/apps/web/src/lib/components/spaces/SpaceForm.svelte b/chat/apps/web/src/lib/components/spaces/SpaceForm.svelte new file mode 100644 index 000000000..4738d5338 --- /dev/null +++ b/chat/apps/web/src/lib/components/spaces/SpaceForm.svelte @@ -0,0 +1,102 @@ + + +
+

+ {isEditMode ? 'Space bearbeiten' : 'Neuen Space erstellen'} +

+ +
{ e.preventDefault(); handleSubmit(); }} class="space-y-5"> + +
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ + +
+ + +
+ + +
+ + +
+
+
diff --git a/chat/apps/web/src/lib/components/templates/TemplateCard.svelte b/chat/apps/web/src/lib/components/templates/TemplateCard.svelte new file mode 100644 index 000000000..296d8386f --- /dev/null +++ b/chat/apps/web/src/lib/components/templates/TemplateCard.svelte @@ -0,0 +1,114 @@ + + +
+ +
+ + +
+
+
+
+

+ {template.name} +

+ {#if template.is_default} + + Standard + + {/if} +
+ + {#if template.description} +

+ {template.description} +

+ {/if} + +

+ {truncatePrompt(template.system_prompt)} +

+
+ + +
+ {#if !template.is_default} + + {/if} + + +
+
+ + + +
+
diff --git a/chat/apps/web/src/lib/components/templates/TemplateForm.svelte b/chat/apps/web/src/lib/components/templates/TemplateForm.svelte new file mode 100644 index 000000000..55e351f99 --- /dev/null +++ b/chat/apps/web/src/lib/components/templates/TemplateForm.svelte @@ -0,0 +1,265 @@ + + +
+

+ {isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'} +

+ +
{ e.preventDefault(); handleSubmit(); }} class="space-y-5"> + +
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ + +
+ + +
+ + +
+ + + {#if errors.systemPrompt} +

{errors.systemPrompt}

+ {:else} +

+ Der System-Prompt definiert die Rolle und das Verhalten der KI. +

+ {/if} +
+ + +
+ + +

+ Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird. +

+
+ + +
+ +
+ {#each TEMPLATE_COLORS as color} + + {/each} +
+
+ + +
+ + +

+ Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet. +

+
+ + +
+ +
+ + +
+ + +
+
+
diff --git a/chat/apps/web/src/lib/services/api.ts b/chat/apps/web/src/lib/services/api.ts new file mode 100644 index 000000000..abf6f99bb --- /dev/null +++ b/chat/apps/web/src/lib/services/api.ts @@ -0,0 +1,52 @@ +/** + * Backend API Client for Chat + */ + +import { env } from '$env/dynamic/public'; + +const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3001'; + +interface ApiResponse { + data?: T; + error?: string; +} + +export async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise> { + try { + const response = await fetch(`${BACKEND_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { error: errorText || `HTTP ${response.status}` }; + } + + const data = await response.json(); + return { data }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +export const api = { + get: (endpoint: string) => apiRequest(endpoint, { method: 'GET' }), + post: (endpoint: string, body: unknown) => + apiRequest(endpoint, { + method: 'POST', + body: JSON.stringify(body), + }), + put: (endpoint: string, body: unknown) => + apiRequest(endpoint, { + method: 'PUT', + body: JSON.stringify(body), + }), + delete: (endpoint: string) => apiRequest(endpoint, { method: 'DELETE' }), +}; diff --git a/chat/apps/web/src/lib/services/chat.ts b/chat/apps/web/src/lib/services/chat.ts new file mode 100644 index 000000000..ec66491ab --- /dev/null +++ b/chat/apps/web/src/lib/services/chat.ts @@ -0,0 +1,46 @@ +/** + * Chat Service - AI Completions via Backend + */ + +import { api } from './api'; +import type { ChatMessage, ChatCompletionResponse, AIModel } from '@chat/types'; + +export interface ChatCompletionRequest { + messages: ChatMessage[]; + modelId: string; + temperature?: number; + maxTokens?: number; +} + +export const chatService = { + /** + * Get available AI models + */ + async getModels(): Promise { + const { data, error } = await api.get('/api/chat/models'); + if (error) { + console.error('Failed to fetch models:', error); + return []; + } + return data || []; + }, + + /** + * Send chat completion request + */ + async createCompletion(request: ChatCompletionRequest): Promise { + const { data, error } = await api.post('/api/chat/completions', { + messages: request.messages, + modelId: request.modelId, + temperature: request.temperature ?? 0.7, + maxTokens: request.maxTokens ?? 1000, + }); + + if (error) { + console.error('Chat completion failed:', error); + return null; + } + + return data || null; + }, +}; diff --git a/chat/apps/web/src/lib/services/conversation.ts b/chat/apps/web/src/lib/services/conversation.ts new file mode 100644 index 000000000..2b6cde68a --- /dev/null +++ b/chat/apps/web/src/lib/services/conversation.ts @@ -0,0 +1,351 @@ +/** + * Conversation Service - CRUD operations via Supabase + */ + +import { createSupabaseBrowserClient } from './supabase'; +import { chatService } from './chat'; +import type { Conversation, Message, ChatMessage } from '@chat/types'; + +let supabase: ReturnType | null = null; + +function getSupabase() { + if (!supabase) { + supabase = createSupabaseBrowserClient(); + } + return supabase; +} + +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 { + const sb = getSupabase(); + + const { data, error } = await sb + .from('conversations') + .insert({ + user_id: userId, + model_id: modelId, + template_id: templateId, + conversation_mode: mode, + document_mode: documentMode, + space_id: spaceId, + }) + .select('id') + .single(); + + if (error) { + console.error('Error creating conversation:', error); + return null; + } + + return data.id; + }, + + /** + * Get all active conversations for a user + */ + async getConversations(userId: string, spaceId?: string): Promise { + const sb = getSupabase(); + + let query = sb + .from('conversations') + .select('*') + .eq('user_id', userId) + .eq('is_archived', false); + + if (spaceId) { + query = query.eq('space_id', spaceId); + } + + const { data, error } = await query.order('updated_at', { ascending: false }); + + if (error) { + console.error('Error loading conversations:', error); + return []; + } + + return data as Conversation[]; + }, + + /** + * Get archived conversations + */ + async getArchivedConversations(userId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('conversations') + .select('*') + .eq('user_id', userId) + .eq('is_archived', true) + .order('updated_at', { ascending: false }); + + if (error) { + console.error('Error loading archived conversations:', error); + return []; + } + + return data as Conversation[]; + }, + + /** + * Get a single conversation + */ + async getConversation(conversationId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('conversations') + .select('*') + .eq('id', conversationId) + .single(); + + if (error) { + console.error('Error loading conversation:', error); + return null; + } + + return data as Conversation; + }, + + /** + * Get messages for a conversation + */ + async getMessages(conversationId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('messages') + .select('*') + .eq('conversation_id', conversationId) + .order('created_at', { ascending: true }); + + if (error) { + console.error('Error loading messages:', error); + return []; + } + + return data as Message[]; + }, + + /** + * Add a message to a conversation + */ + async addMessage( + conversationId: string, + sender: 'user' | 'assistant' | 'system', + messageText: string + ): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('messages') + .insert({ + conversation_id: conversationId, + sender, + message_text: messageText, + }) + .select('id') + .single(); + + if (error) { + console.error('Error adding message:', error); + return null; + } + + return data.id; + }, + + /** + * Update conversation title + */ + async updateTitle(conversationId: string, title: string): Promise { + const sb = getSupabase(); + + const { error } = await sb + .from('conversations') + .update({ title, updated_at: new Date().toISOString() }) + .eq('id', conversationId); + + if (error) { + console.error('Error updating title:', error); + return false; + } + + return true; + }, + + /** + * Archive a conversation + */ + async archiveConversation(conversationId: string): Promise { + const sb = getSupabase(); + + const { error } = await sb + .from('conversations') + .update({ is_archived: true }) + .eq('id', conversationId); + + if (error) { + console.error('Error archiving conversation:', error); + return false; + } + + return true; + }, + + /** + * Unarchive a conversation + */ + async unarchiveConversation(conversationId: string): Promise { + const sb = getSupabase(); + + const { error } = await sb + .from('conversations') + .update({ is_archived: false }) + .eq('id', conversationId); + + if (error) { + console.error('Error unarchiving conversation:', error); + return false; + } + + return true; + }, + + /** + * Delete a conversation permanently + */ + async deleteConversation(conversationId: string): Promise { + const sb = getSupabase(); + + // Delete messages first + const { error: messagesError } = await sb + .from('messages') + .delete() + .eq('conversation_id', conversationId); + + if (messagesError) { + console.error('Error deleting messages:', messagesError); + return false; + } + + // Delete conversation + const { error: conversationError } = await sb + .from('conversations') + .delete() + .eq('id', conversationId); + + if (conversationError) { + console.error('Error deleting conversation:', conversationError); + return false; + } + + return true; + }, + + /** + * 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.message_text, + })); + + // Get AI response + const response = await chatService.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); + + // Update conversation timestamp + const sb = getSupabase(); + await sb + .from('conversations') + .update({ updated_at: new Date().toISOString() }) + .eq('id', conversationId); + + // 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 { + const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`; + + const response = await chatService.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; + }, +}; diff --git a/chat/apps/web/src/lib/services/document.ts b/chat/apps/web/src/lib/services/document.ts new file mode 100644 index 000000000..42ccd2272 --- /dev/null +++ b/chat/apps/web/src/lib/services/document.ts @@ -0,0 +1,176 @@ +/** + * Document Service - Manage documents in document mode conversations + */ + +import { createSupabaseBrowserClient } from './supabase'; +import type { Document, DocumentWithConversation } from '@chat/types'; + +let supabase: ReturnType | null = null; + +function getSupabase() { + if (!supabase) { + supabase = createSupabaseBrowserClient(); + } + return supabase; +} + +export const documentService = { + /** + * Get all documents for a user (latest version of each) + */ + async getUserDocuments(userId: string): Promise { + const sb = getSupabase(); + + // Get all conversations with document_mode enabled + const { data: conversations, error: convError } = await sb + .from('conversations') + .select('id, title, document_mode') + .eq('user_id', userId) + .eq('document_mode', true); + + if (convError) { + console.error('Error loading conversations:', convError); + return []; + } + + if (!conversations || conversations.length === 0) { + return []; + } + + // For each conversation, load the latest document version + const documents: DocumentWithConversation[] = []; + + for (const conv of conversations) { + const { data: docData, error: docError } = await sb + .from('documents') + .select('*') + .eq('conversation_id', conv.id) + .order('version', { ascending: false }) + .limit(1) + .single(); + + if (docError && docError.code !== 'PGRST116') { + console.error(`Error loading document for conversation ${conv.id}:`, docError); + continue; + } + + if (docData) { + documents.push({ + ...docData, + conversation_title: conv.title || 'Unbenannte Konversation', + }); + } + } + + return documents; + }, + + /** + * Get the latest document for a conversation + */ + async getLatestDocument(conversationId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('documents') + .select('*') + .eq('conversation_id', conversationId) + .order('version', { ascending: false }) + .limit(1) + .single(); + + if (error) { + if (error.code !== 'PGRST116') { + console.error('Error loading document:', error); + } + return null; + } + + return data as Document; + }, + + /** + * Create a new document + */ + async createDocument(conversationId: string, content: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('documents') + .insert({ + conversation_id: conversationId, + version: 1, + content, + }) + .select() + .single(); + + if (error) { + console.error('Error creating document:', error); + return null; + } + + return data as Document; + }, + + /** + * Create a new version of a document + */ + async createDocumentVersion(conversationId: string, content: string): Promise { + const sb = getSupabase(); + + // Get the current highest version + const { data: latestVersionData, error: versionError } = await sb + .from('documents') + .select('version') + .eq('conversation_id', conversationId) + .order('version', { ascending: false }) + .limit(1) + .single(); + + if (versionError && versionError.code !== 'PGRST116') { + console.error('Error loading latest document version:', versionError); + return null; + } + + const newVersion = (latestVersionData?.version || 0) + 1; + + // Create a new document version + const { data, error } = await sb + .from('documents') + .insert({ + conversation_id: conversationId, + version: newVersion, + content, + }) + .select() + .single(); + + if (error) { + console.error('Error creating document version:', error); + return null; + } + + return data as Document; + }, + + /** + * Get all versions of a document + */ + async getAllDocumentVersions(conversationId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('documents') + .select('*') + .eq('conversation_id', conversationId) + .order('version', { ascending: false }); + + if (error) { + console.error('Error loading document versions:', error); + return []; + } + + return data as Document[]; + }, +}; diff --git a/chat/apps/web/src/lib/services/space.ts b/chat/apps/web/src/lib/services/space.ts new file mode 100644 index 000000000..e70ebbf62 --- /dev/null +++ b/chat/apps/web/src/lib/services/space.ts @@ -0,0 +1,214 @@ +/** + * Space Service - CRUD operations via Supabase + */ + +import { createSupabaseBrowserClient } from './supabase'; +import type { Space, SpaceMember, SpaceCreate, SpaceUpdate } from '@chat/types'; + +let supabase: ReturnType | null = null; + +function getSupabase() { + if (!supabase) { + supabase = createSupabaseBrowserClient(); + } + return supabase; +} + +export const spaceService = { + /** + * Get all spaces for a user (both owned and member of) + */ + async getUserSpaces(userId: string): Promise { + const sb = getSupabase(); + + // Get space IDs the user is a member of (with accepted status) + const { data: memberData, error: memberError } = await sb + .from('space_members') + .select('space_id') + .eq('user_id', userId) + .eq('invitation_status', 'accepted'); + + if (memberError) { + console.error('Error fetching user space memberships:', memberError); + return []; + } + + if (!memberData || memberData.length === 0) { + return []; + } + + const spaceIds = memberData.map((m) => m.space_id); + + // Fetch the actual space data + const { data: spaces, error: spacesError } = await sb + .from('spaces') + .select('*') + .in('id', spaceIds) + .eq('is_archived', false) + .order('created_at', { ascending: false }); + + if (spacesError) { + console.error('Error fetching spaces:', spacesError); + return []; + } + + return spaces as Space[]; + }, + + /** + * Get a single space by ID + */ + async getSpace(spaceId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb.from('spaces').select('*').eq('id', spaceId).single(); + + if (error) { + console.error('Error fetching space:', error); + return null; + } + + return data as Space; + }, + + /** + * Create a new space + */ + async createSpace(space: SpaceCreate): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('spaces') + .insert({ + name: space.name, + description: space.description, + owner_id: space.owner_id, + }) + .select('id') + .single(); + + if (error) { + console.error('Error creating space:', error); + return null; + } + + return data.id; + }, + + /** + * Update a space + */ + async updateSpace(spaceId: string, updates: SpaceUpdate): Promise { + const sb = getSupabase(); + + const { error } = await sb.from('spaces').update(updates).eq('id', spaceId); + + if (error) { + console.error('Error updating space:', error); + return false; + } + + return true; + }, + + /** + * Delete a space + */ + async deleteSpace(spaceId: string): Promise { + const sb = getSupabase(); + + const { error } = await sb.from('spaces').delete().eq('id', spaceId); + + if (error) { + console.error('Error deleting space:', error); + return false; + } + + return true; + }, + + /** + * Get members of a space + */ + async getSpaceMembers(spaceId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('space_members') + .select('*') + .eq('space_id', spaceId) + .order('role', { ascending: true }) + .order('joined_at', { ascending: false }); + + if (error) { + console.error('Error fetching space members:', error); + return []; + } + + return data as SpaceMember[]; + }, + + /** + * Get user's role in a space + */ + async getUserRoleInSpace( + spaceId: string, + userId: string + ): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> { + const sb = getSupabase(); + + // First check if they're the owner + const { data: space, error: spaceError } = await sb + .from('spaces') + .select('owner_id') + .eq('id', spaceId) + .single(); + + if (spaceError) { + console.error('Error checking space ownership:', spaceError); + return null; + } + + if (space.owner_id === userId) { + return 'owner'; + } + + // If not owner, check membership + const { data: member, error: memberError } = await sb + .from('space_members') + .select('role, invitation_status') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError) { + return null; + } + + if (member && member.invitation_status === 'accepted') { + return member.role as 'admin' | 'member' | 'viewer'; + } + + return null; + }, + + /** + * Leave a space + */ + async leaveSpace(spaceId: string, userId: string): Promise { + const sb = getSupabase(); + + const { error } = await sb + .from('space_members') + .delete() + .eq('space_id', spaceId) + .eq('user_id', userId); + + if (error) { + console.error('Error leaving space:', error); + return false; + } + + return true; + }, +}; diff --git a/chat/apps/web/src/lib/services/supabase.ts b/chat/apps/web/src/lib/services/supabase.ts new file mode 100644 index 000000000..3db9c50a3 --- /dev/null +++ b/chat/apps/web/src/lib/services/supabase.ts @@ -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); diff --git a/chat/apps/web/src/lib/services/template.ts b/chat/apps/web/src/lib/services/template.ts new file mode 100644 index 000000000..c67cdeb2c --- /dev/null +++ b/chat/apps/web/src/lib/services/template.ts @@ -0,0 +1,157 @@ +/** + * Template Service - CRUD operations via Supabase + */ + +import { createSupabaseBrowserClient } from './supabase'; +import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types'; + +let supabase: ReturnType | null = null; + +function getSupabase() { + if (!supabase) { + supabase = createSupabaseBrowserClient(); + } + return supabase; +} + +export const templateService = { + /** + * Get all templates for a user + */ + async getTemplates(userId: string): Promise { + const sb = getSupabase(); + + const { data, error } = await sb + .from('templates') + .select('*') + .eq('user_id', userId) + .order('name'); + + if (error) { + console.error('Error loading templates:', error); + return []; + } + + return data as Template[]; + }, + + /** + * Get a single template by ID + */ + async getTemplate(templateId: string): Promise