diff --git a/apps/questions/CLAUDE.md b/apps/questions/CLAUDE.md
index 218d67461..e5826e3e3 100644
--- a/apps/questions/CLAUDE.md
+++ b/apps/questions/CLAUDE.md
@@ -5,7 +5,8 @@ AI-powered research assistant that collects user questions and performs comprehe
## Overview
- **Backend Port**: 3011
-- **Technology**: NestJS + Drizzle ORM + PostgreSQL
+- **Web Port**: 5111
+- **Technology**: NestJS + Drizzle ORM + PostgreSQL + SvelteKit
- **Search**: mana-search microservice (SearXNG)
## Architecture
@@ -33,16 +34,25 @@ AI-powered research assistant that collects user questions and performs comprehe
# 1. Start infrastructure (PostgreSQL, Redis, mana-search dependencies)
pnpm docker:up
-# 2. Start mana-search service
-pnpm dev:search:full
-
-# 3. Start questions backend
-pnpm dev:questions:backend
-
-# Or use the combined command:
+# 2. Start everything (auth, search, backend, web):
pnpm dev:questions:full
+
+# Or start components individually:
+pnpm dev:questions:backend # Just backend (port 3011)
+pnpm dev:questions:web # Just web (port 5111)
+pnpm dev:search:full # Just search service (port 3021)
```
+## Web App
+
+The SvelteKit web app provides:
+
+- **Question Management**: Create, edit, and organize questions
+- **Collection Organization**: Group questions into collections with colors/icons
+- **Research Interface**: Start research and view results with sources
+- **Source Viewer**: Explore extracted content from web sources
+- **Dark Mode**: Full theme support
+
## API Endpoints
### Collections
diff --git a/apps/questions/apps/web/.env.example b/apps/questions/apps/web/.env.example
new file mode 100644
index 000000000..18fefce29
--- /dev/null
+++ b/apps/questions/apps/web/.env.example
@@ -0,0 +1,7 @@
+# Questions Web App Environment Variables
+
+# Backend API URL
+PUBLIC_BACKEND_URL=http://localhost:3011
+
+# Mana Core Auth URL
+PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
diff --git a/apps/questions/apps/web/package.json b/apps/questions/apps/web/package.json
new file mode 100644
index 000000000..2cea9c910
--- /dev/null
+++ b/apps/questions/apps/web/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@questions/web",
+ "version": "1.0.0",
+ "private": true,
+ "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",
+ "lint": "eslint .",
+ "format": "prettier --write .",
+ "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "^5.0.0",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.1.7",
+ "@types/node": "^20.0.0",
+ "prettier": "^3.1.1",
+ "prettier-plugin-svelte": "^3.1.2",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.1.7",
+ "tslib": "^2.4.1",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0"
+ },
+ "dependencies": {
+ "@manacore/shared-auth": "workspace:*",
+ "@manacore/shared-types": "workspace:*",
+ "@manacore/shared-utils": "workspace:*",
+ "@manacore/shared-auth-ui": "workspace:*",
+ "@manacore/shared-branding": "workspace:*",
+ "@manacore/shared-i18n": "workspace:*",
+ "@manacore/shared-icons": "workspace:*",
+ "@manacore/shared-tailwind": "workspace:*",
+ "@manacore/shared-theme": "workspace:*",
+ "@manacore/shared-theme-ui": "workspace:*",
+ "@manacore/shared-ui": "workspace:*",
+ "date-fns": "^4.1.0",
+ "lucide-svelte": "^0.556.0",
+ "svelte-i18n": "^4.0.1"
+ },
+ "type": "module"
+}
diff --git a/apps/questions/apps/web/src/app.css b/apps/questions/apps/web/src/app.css
new file mode 100644
index 000000000..348e01617
--- /dev/null
+++ b/apps/questions/apps/web/src/app.css
@@ -0,0 +1,185 @@
+@import 'tailwindcss';
+@import '@manacore/shared-tailwind/themes.css';
+
+/* Scan shared packages for Tailwind classes */
+@source "../../../../../packages/shared-ui/src";
+@source "../../../../../packages/shared-theme-ui/src";
+@source "../../../../../packages/shared-theme-ui/src/components";
+@source "../../../../../packages/shared-theme-ui/src/pages";
+
+:root {
+ /* Questions App - Indigo/Blue Theme */
+ --color-primary: #6366f1;
+ --color-primary-hover: #4f46e5;
+ --color-primary-light: #818cf8;
+ --color-primary-dark: #4338ca;
+
+ --color-secondary: #e0e7ff;
+ --color-secondary-hover: #c7d2fe;
+
+ --color-accent: #8b5cf6;
+ --color-accent-hover: #7c3aed;
+
+ /* Question status colors */
+ --color-status-open: #6b7280;
+ --color-status-researching: #3b82f6;
+ --color-status-answered: #22c55e;
+ --color-status-archived: #9ca3af;
+
+ /* Research depth colors */
+ --color-depth-quick: #22c55e;
+ --color-depth-standard: #eab308;
+ --color-depth-deep: #8b5cf6;
+
+ /* Priority colors */
+ --color-priority-low: #6b7280;
+ --color-priority-normal: #3b82f6;
+ --color-priority-high: #f97316;
+ --color-priority-urgent: #ef4444;
+}
+
+/* Dark mode overrides */
+:root.dark {
+ --color-secondary: #1e1b4b;
+ --color-secondary-hover: #2e1065;
+}
+
+/* Question card transitions */
+.question-card {
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease,
+ border-color 0.15s ease;
+}
+
+.question-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+/* Collection item styling */
+.collection-item {
+ transition: background-color 0.15s ease;
+}
+
+.collection-item:hover {
+ background-color: var(--color-secondary);
+}
+
+.collection-item.active {
+ background-color: var(--color-secondary);
+ border-left: 3px solid var(--color-primary);
+}
+
+/* Research progress animation */
+.research-progress {
+ background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-accent) 100%);
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+}
+
+/* Source card */
+.source-card {
+ transition: all 0.15s ease;
+ border-left: 3px solid transparent;
+}
+
+.source-card:hover {
+ border-left-color: var(--color-primary);
+ background-color: var(--color-secondary);
+}
+
+/* Answer styling */
+.answer-accepted {
+ border: 2px solid var(--color-status-answered);
+ background-color: rgba(34, 197, 94, 0.05);
+}
+
+/* Tag badges */
+.tag-badge {
+ transition: all 0.15s ease;
+}
+
+.tag-badge:hover {
+ transform: scale(1.05);
+}
+
+/* Depth indicator */
+.depth-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.depth-quick {
+ background-color: rgba(34, 197, 94, 0.1);
+ color: var(--color-depth-quick);
+}
+
+.depth-standard {
+ background-color: rgba(234, 179, 8, 0.1);
+ color: var(--color-depth-standard);
+}
+
+.depth-deep {
+ background-color: rgba(139, 92, 246, 0.1);
+ color: var(--color-depth-deep);
+}
+
+/* Markdown content styling */
+.markdown-content {
+ line-height: 1.7;
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3 {
+ margin-top: 1.5em;
+ margin-bottom: 0.5em;
+ font-weight: 600;
+}
+
+.markdown-content p {
+ margin-bottom: 1em;
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ margin-left: 1.5em;
+ margin-bottom: 1em;
+}
+
+.markdown-content code {
+ background-color: var(--color-secondary);
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ font-size: 0.875em;
+}
+
+.markdown-content pre {
+ background-color: var(--color-secondary);
+ padding: 1rem;
+ border-radius: 0.5rem;
+ overflow-x: auto;
+ margin-bottom: 1em;
+}
+
+.markdown-content blockquote {
+ border-left: 3px solid var(--color-primary);
+ padding-left: 1rem;
+ margin-left: 0;
+ color: var(--color-text-muted);
+}
diff --git a/apps/questions/apps/web/src/app.d.ts b/apps/questions/apps/web/src/app.d.ts
new file mode 100644
index 000000000..743f07b2e
--- /dev/null
+++ b/apps/questions/apps/web/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/apps/questions/apps/web/src/app.html b/apps/questions/apps/web/src/app.html
new file mode 100644
index 000000000..3908e9ccf
--- /dev/null
+++ b/apps/questions/apps/web/src/app.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/questions/apps/web/src/lib/api/answers.ts b/apps/questions/apps/web/src/lib/api/answers.ts
new file mode 100644
index 000000000..c45fc0e38
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/answers.ts
@@ -0,0 +1,33 @@
+import { apiClient } from './client';
+import type { Answer } from '$lib/types';
+
+export interface RateAnswerDto {
+ rating: number;
+ feedback?: string;
+}
+
+export const answersApi = {
+ async getByQuestion(questionId: string): Promise {
+ return apiClient.get(`/api/v1/answers/question/${questionId}`);
+ },
+
+ async getAccepted(questionId: string): Promise {
+ return apiClient.get(`/api/v1/answers/question/${questionId}/accepted`);
+ },
+
+ async getById(id: string): Promise {
+ return apiClient.get(`/api/v1/answers/${id}`);
+ },
+
+ async rate(id: string, data: RateAnswerDto): Promise {
+ return apiClient.post(`/api/v1/answers/${id}/rate`, data);
+ },
+
+ async accept(id: string, isAccepted: boolean): Promise {
+ return apiClient.post(`/api/v1/answers/${id}/accept`, { isAccepted });
+ },
+
+ async delete(id: string): Promise {
+ await apiClient.delete(`/api/v1/answers/${id}`);
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/api/client.ts b/apps/questions/apps/web/src/lib/api/client.ts
new file mode 100644
index 000000000..b874f2920
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/client.ts
@@ -0,0 +1,97 @@
+import { browser } from '$app/environment';
+import { PUBLIC_BACKEND_URL } from '$env/static/public';
+
+interface ApiOptions {
+ method?: string;
+ body?: unknown;
+ headers?: Record;
+}
+
+interface ApiError {
+ message: string;
+ statusCode: number;
+}
+
+/**
+ * Get the backend URL, preferring runtime-injected value in browser
+ */
+function getBackendUrl(): string {
+ if (browser && typeof window !== 'undefined') {
+ const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
+ .__PUBLIC_BACKEND_URL__;
+ if (runtimeUrl) {
+ return runtimeUrl;
+ }
+ }
+ return PUBLIC_BACKEND_URL || 'http://localhost:3011';
+}
+
+class ApiClient {
+ private accessToken: string | null = null;
+
+ private get baseUrl(): string {
+ return getBackendUrl();
+ }
+
+ setAccessToken(token: string | null) {
+ this.accessToken = token;
+ }
+
+ getAccessToken(): string | null {
+ return this.accessToken;
+ }
+
+ async fetch(endpoint: string, options: ApiOptions = {}): Promise {
+ const { method = 'GET', body, headers = {} } = options;
+
+ const requestHeaders: Record = {
+ 'Content-Type': 'application/json',
+ ...headers,
+ };
+
+ if (this.accessToken) {
+ requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
+ }
+
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
+ method,
+ headers: requestHeaders,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ if (!response.ok) {
+ let errorMessage = 'An error occurred';
+ try {
+ const errorData = (await response.json()) as ApiError;
+ errorMessage = errorData.message || errorMessage;
+ } catch {
+ errorMessage = response.statusText || errorMessage;
+ }
+ throw new Error(errorMessage);
+ }
+
+ if (response.status === 204) {
+ return {} as T;
+ }
+
+ return response.json() as Promise;
+ }
+
+ get(endpoint: string, headers?: Record): Promise {
+ return this.fetch(endpoint, { method: 'GET', headers });
+ }
+
+ post(endpoint: string, body?: unknown, headers?: Record): Promise {
+ return this.fetch(endpoint, { method: 'POST', body, headers });
+ }
+
+ put(endpoint: string, body?: unknown, headers?: Record): Promise {
+ return this.fetch(endpoint, { method: 'PUT', body, headers });
+ }
+
+ delete(endpoint: string, headers?: Record): Promise {
+ return this.fetch(endpoint, { method: 'DELETE', headers });
+ }
+}
+
+export const apiClient = new ApiClient();
diff --git a/apps/questions/apps/web/src/lib/api/collections.ts b/apps/questions/apps/web/src/lib/api/collections.ts
new file mode 100644
index 000000000..9ec8995b1
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/collections.ts
@@ -0,0 +1,32 @@
+import { apiClient } from './client';
+import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
+
+export const collectionsApi = {
+ async getAll(): Promise {
+ return apiClient.get('/api/v1/collections');
+ },
+
+ async getById(id: string): Promise {
+ return apiClient.get(`/api/v1/collections/${id}`);
+ },
+
+ async getDefault(): Promise {
+ return apiClient.get('/api/v1/collections/default');
+ },
+
+ async create(data: CreateCollectionDto): Promise {
+ return apiClient.post('/api/v1/collections', data);
+ },
+
+ async update(id: string, data: UpdateCollectionDto): Promise {
+ return apiClient.put(`/api/v1/collections/${id}`, data);
+ },
+
+ async delete(id: string): Promise {
+ await apiClient.delete(`/api/v1/collections/${id}`);
+ },
+
+ async reorder(orderedIds: string[]): Promise {
+ await apiClient.post('/api/v1/collections/reorder', { orderedIds });
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/api/index.ts b/apps/questions/apps/web/src/lib/api/index.ts
new file mode 100644
index 000000000..48d25f2ea
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/index.ts
@@ -0,0 +1,6 @@
+export { apiClient } from './client';
+export { questionsApi } from './questions';
+export { collectionsApi } from './collections';
+export { researchApi } from './research';
+export { answersApi } from './answers';
+export { sourcesApi } from './sources';
diff --git a/apps/questions/apps/web/src/lib/api/questions.ts b/apps/questions/apps/web/src/lib/api/questions.ts
new file mode 100644
index 000000000..e20de37af
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/questions.ts
@@ -0,0 +1,53 @@
+import { apiClient } from './client';
+import type {
+ Question,
+ CreateQuestionDto,
+ UpdateQuestionDto,
+ PaginatedResponse,
+} from '$lib/types';
+
+export interface QuestionFilters {
+ collectionId?: string;
+ status?: string;
+ search?: string;
+ tags?: string[];
+ limit?: number;
+ offset?: number;
+}
+
+export const questionsApi = {
+ async getAll(filters?: QuestionFilters): Promise> {
+ const params = new URLSearchParams();
+ if (filters?.collectionId) params.set('collectionId', filters.collectionId);
+ if (filters?.status) params.set('status', filters.status);
+ if (filters?.search) params.set('search', filters.search);
+ if (filters?.tags?.length) params.set('tags', filters.tags.join(','));
+ if (filters?.limit) params.set('limit', filters.limit.toString());
+ if (filters?.offset) params.set('offset', filters.offset.toString());
+
+ const query = params.toString();
+ return apiClient.get>(
+ `/api/v1/questions${query ? `?${query}` : ''}`,
+ );
+ },
+
+ async getById(id: string): Promise {
+ return apiClient.get(`/api/v1/questions/${id}`);
+ },
+
+ async create(data: CreateQuestionDto): Promise {
+ return apiClient.post('/api/v1/questions', data);
+ },
+
+ async update(id: string, data: UpdateQuestionDto): Promise {
+ return apiClient.put(`/api/v1/questions/${id}`, data);
+ },
+
+ async delete(id: string): Promise {
+ await apiClient.delete(`/api/v1/questions/${id}`);
+ },
+
+ async updateStatus(id: string, status: string): Promise {
+ return apiClient.put(`/api/v1/questions/${id}/status`, { status });
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/api/research.ts b/apps/questions/apps/web/src/lib/api/research.ts
new file mode 100644
index 000000000..8abcbaf65
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/research.ts
@@ -0,0 +1,20 @@
+import { apiClient } from './client';
+import type { ResearchResult, StartResearchDto } from '$lib/types';
+
+export const researchApi = {
+ async start(data: StartResearchDto): Promise {
+ return apiClient.post('/api/v1/research/start', data);
+ },
+
+ async getByQuestion(questionId: string): Promise {
+ return apiClient.get(`/api/v1/research/question/${questionId}`);
+ },
+
+ async getById(id: string): Promise {
+ return apiClient.get(`/api/v1/research/${id}`);
+ },
+
+ async checkHealth(): Promise<{ service: string; status: string }> {
+ return apiClient.get('/api/v1/research/health/search');
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/api/sources.ts b/apps/questions/apps/web/src/lib/api/sources.ts
new file mode 100644
index 000000000..c7a353f0c
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/api/sources.ts
@@ -0,0 +1,20 @@
+import { apiClient } from './client';
+import type { Source } from '$lib/types';
+
+export const sourcesApi = {
+ async getByResearchResult(researchResultId: string): Promise {
+ return apiClient.get(`/api/v1/sources/research/${researchResultId}`);
+ },
+
+ async getByQuestion(questionId: string): Promise {
+ return apiClient.get(`/api/v1/sources/question/${questionId}`);
+ },
+
+ async getById(id: string): Promise {
+ return apiClient.get(`/api/v1/sources/${id}`);
+ },
+
+ async getContent(id: string): Promise<{ text: string; markdown?: string }> {
+ return apiClient.get(`/api/v1/sources/${id}/content`);
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts
new file mode 100644
index 000000000..443ddd858
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts
@@ -0,0 +1,186 @@
+/**
+ * Auth Store - Manages authentication state using Svelte 5 runes
+ * Uses Mana Core Auth
+ */
+
+import { browser } from '$app/environment';
+import { initializeWebAuth } from '@manacore/shared-auth';
+import type { UserData } from '@manacore/shared-auth';
+
+function getAuthUrl(): string {
+ if (browser && typeof window !== 'undefined') {
+ const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
+ .__PUBLIC_MANA_CORE_AUTH_URL__;
+ return injectedUrl || 'http://localhost:3001';
+ }
+ return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
+}
+
+function getBackendUrl(): string {
+ if (browser && typeof window !== 'undefined') {
+ const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
+ .__PUBLIC_BACKEND_URL__;
+ return injectedUrl || 'http://localhost:3011';
+ }
+ return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3011';
+}
+
+let _authService: ReturnType['authService'] | null = null;
+let _tokenManager: ReturnType['tokenManager'] | null = null;
+
+function getAuthService() {
+ if (!browser) return null;
+ if (!_authService) {
+ const auth = initializeWebAuth({
+ baseUrl: getAuthUrl(),
+ backendUrl: getBackendUrl(),
+ });
+ _authService = auth.authService;
+ _tokenManager = auth.tokenManager;
+ }
+ return _authService;
+}
+
+function getTokenManager() {
+ if (!browser) return null;
+ getAuthService();
+ return _tokenManager;
+}
+
+let user = $state(null);
+let loading = $state(true);
+let initialized = $state(false);
+
+export const authStore = {
+ get user() {
+ return user;
+ },
+ get loading() {
+ return loading;
+ },
+ get isAuthenticated() {
+ return !!user;
+ },
+ get initialized() {
+ return initialized;
+ },
+
+ 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;
+ }
+ },
+
+ 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' };
+ }
+
+ const userData = await authService.getUserFromToken();
+ user = userData;
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: errorMessage };
+ }
+ },
+
+ async signUp(email: string, password: string) {
+ const authService = getAuthService();
+ if (!authService) {
+ return { success: false, error: 'Auth not available on server', needsVerification: false };
+ }
+
+ try {
+ const sourceAppUrl = browser ? window.location.origin : undefined;
+ const result = await authService.signUp(email, password, undefined, sourceAppUrl);
+
+ if (!result.success) {
+ return { success: false, error: result.error || 'Signup failed', needsVerification: false };
+ }
+
+ if (result.needsVerification) {
+ return { success: true, needsVerification: true };
+ }
+
+ 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 };
+ }
+ },
+
+ async signOut() {
+ const authService = getAuthService();
+ if (!authService) {
+ user = null;
+ return;
+ }
+
+ try {
+ await authService.signOut();
+ user = null;
+ } catch (error) {
+ console.error('Sign out error:', error);
+ user = null;
+ }
+ },
+
+ 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 };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: errorMessage };
+ }
+ },
+
+ async getValidToken(): Promise {
+ const tokenManager = getTokenManager();
+ if (!tokenManager) {
+ return null;
+ }
+ return await tokenManager.getValidToken();
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/stores/collections.svelte.ts b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts
new file mode 100644
index 000000000..71f4797b0
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts
@@ -0,0 +1,119 @@
+/**
+ * Collections Store - Manages collections state using Svelte 5 runes
+ */
+
+import { collectionsApi } from '$lib/api/collections';
+import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
+
+let collections = $state([]);
+let loading = $state(false);
+let error = $state(null);
+let selectedId = $state(null);
+
+export const collectionsStore = {
+ get collections() {
+ return collections;
+ },
+ get loading() {
+ return loading;
+ },
+ get error() {
+ return error;
+ },
+ get selectedId() {
+ return selectedId;
+ },
+ get selected() {
+ return selectedId ? collections.find((c) => c.id === selectedId) : null;
+ },
+
+ async load() {
+ loading = true;
+ error = null;
+
+ try {
+ collections = await collectionsApi.getAll();
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load collections';
+ collections = [];
+ } finally {
+ loading = false;
+ }
+ },
+
+ async create(data: CreateCollectionDto): Promise {
+ loading = true;
+ error = null;
+
+ try {
+ const collection = await collectionsApi.create(data);
+ collections = [...collections, collection];
+ return collection;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to create collection';
+ return null;
+ } finally {
+ loading = false;
+ }
+ },
+
+ async update(id: string, data: UpdateCollectionDto): Promise {
+ error = null;
+
+ try {
+ const updated = await collectionsApi.update(id, data);
+ collections = collections.map((c) => (c.id === id ? updated : c));
+ return updated;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update collection';
+ return null;
+ }
+ },
+
+ async delete(id: string): Promise {
+ error = null;
+
+ try {
+ await collectionsApi.delete(id);
+ collections = collections.filter((c) => c.id !== id);
+ if (selectedId === id) {
+ selectedId = null;
+ }
+ return true;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete collection';
+ return false;
+ }
+ },
+
+ async reorder(orderedIds: string[]): Promise {
+ error = null;
+
+ try {
+ await collectionsApi.reorder(orderedIds);
+ // Reorder local state
+ const reordered = orderedIds
+ .map((id) => collections.find((c) => c.id === id))
+ .filter((c): c is Collection => c !== undefined);
+ collections = reordered;
+ return true;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to reorder collections';
+ return false;
+ }
+ },
+
+ select(id: string | null) {
+ selectedId = id;
+ },
+
+ getById(id: string): Collection | undefined {
+ return collections.find((c) => c.id === id);
+ },
+
+ clear() {
+ collections = [];
+ error = null;
+ selectedId = null;
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/stores/index.ts b/apps/questions/apps/web/src/lib/stores/index.ts
new file mode 100644
index 000000000..fb294d996
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/stores/index.ts
@@ -0,0 +1,4 @@
+export { authStore } from './auth.svelte';
+export { questionsStore } from './questions.svelte';
+export { collectionsStore } from './collections.svelte';
+export { theme } from './theme';
diff --git a/apps/questions/apps/web/src/lib/stores/questions.svelte.ts b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts
new file mode 100644
index 000000000..b1b8466e1
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts
@@ -0,0 +1,116 @@
+/**
+ * Questions Store - Manages questions state using Svelte 5 runes
+ */
+
+import { questionsApi, type QuestionFilters } from '$lib/api/questions';
+import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types';
+
+let questions = $state([]);
+let loading = $state(false);
+let error = $state(null);
+let total = $state(0);
+let currentFilters = $state({});
+
+export const questionsStore = {
+ get questions() {
+ return questions;
+ },
+ get loading() {
+ return loading;
+ },
+ get error() {
+ return error;
+ },
+ get total() {
+ return total;
+ },
+ get filters() {
+ return currentFilters;
+ },
+
+ async load(filters?: QuestionFilters) {
+ loading = true;
+ error = null;
+ currentFilters = filters || {};
+
+ try {
+ const response = await questionsApi.getAll(filters);
+ questions = response.data;
+ total = response.total;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load questions';
+ questions = [];
+ total = 0;
+ } finally {
+ loading = false;
+ }
+ },
+
+ async create(data: CreateQuestionDto): Promise {
+ loading = true;
+ error = null;
+
+ try {
+ const question = await questionsApi.create(data);
+ questions = [question, ...questions];
+ total++;
+ return question;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to create question';
+ return null;
+ } finally {
+ loading = false;
+ }
+ },
+
+ async update(id: string, data: UpdateQuestionDto): Promise {
+ error = null;
+
+ try {
+ const updated = await questionsApi.update(id, data);
+ questions = questions.map((q) => (q.id === id ? updated : q));
+ return updated;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update question';
+ return null;
+ }
+ },
+
+ async delete(id: string): Promise {
+ error = null;
+
+ try {
+ await questionsApi.delete(id);
+ questions = questions.filter((q) => q.id !== id);
+ total--;
+ return true;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete question';
+ return false;
+ }
+ },
+
+ async updateStatus(id: string, status: string): Promise {
+ error = null;
+
+ try {
+ const updated = await questionsApi.updateStatus(id, status);
+ questions = questions.map((q) => (q.id === id ? updated : q));
+ return updated;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update status';
+ return null;
+ }
+ },
+
+ getById(id: string): Question | undefined {
+ return questions.find((q) => q.id === id);
+ },
+
+ clear() {
+ questions = [];
+ total = 0;
+ error = null;
+ currentFilters = {};
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/stores/theme.ts b/apps/questions/apps/web/src/lib/stores/theme.ts
new file mode 100644
index 000000000..61984650e
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/stores/theme.ts
@@ -0,0 +1,61 @@
+import { browser } from '$app/environment';
+
+type Theme = 'light' | 'dark' | 'system';
+
+function getInitialTheme(): Theme {
+ if (!browser) return 'system';
+
+ const stored = localStorage.getItem('theme') as Theme | null;
+ if (stored && ['light', 'dark', 'system'].includes(stored)) {
+ return stored;
+ }
+ return 'system';
+}
+
+function applyTheme(theme: Theme) {
+ if (!browser) return;
+
+ const root = document.documentElement;
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const isDark = theme === 'dark' || (theme === 'system' && systemDark);
+
+ if (isDark) {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+}
+
+let currentTheme: Theme = 'system';
+
+export const theme = {
+ get current() {
+ return currentTheme;
+ },
+
+ initialize() {
+ currentTheme = getInitialTheme();
+ applyTheme(currentTheme);
+
+ if (browser) {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (currentTheme === 'system') {
+ applyTheme('system');
+ }
+ });
+ }
+ },
+
+ set(newTheme: Theme) {
+ currentTheme = newTheme;
+ if (browser) {
+ localStorage.setItem('theme', newTheme);
+ }
+ applyTheme(newTheme);
+ },
+
+ toggle() {
+ const next = currentTheme === 'light' ? 'dark' : 'light';
+ this.set(next);
+ },
+};
diff --git a/apps/questions/apps/web/src/lib/types/index.ts b/apps/questions/apps/web/src/lib/types/index.ts
new file mode 100644
index 000000000..0fac15bbc
--- /dev/null
+++ b/apps/questions/apps/web/src/lib/types/index.ts
@@ -0,0 +1,148 @@
+export interface Collection {
+ id: string;
+ userId: string;
+ name: string;
+ description?: string;
+ color: string;
+ icon: string;
+ isDefault: boolean;
+ sortOrder: number;
+ createdAt: string;
+ updatedAt: string;
+ questionCount?: number;
+}
+
+export interface Question {
+ id: string;
+ userId: string;
+ collectionId?: string;
+ title: string;
+ description?: string;
+ status: QuestionStatus;
+ priority: QuestionPriority;
+ tags: string[];
+ researchDepth: ResearchDepth;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export type QuestionStatus = 'open' | 'researching' | 'answered' | 'archived';
+export type QuestionPriority = 'low' | 'normal' | 'high' | 'urgent';
+export type ResearchDepth = 'quick' | 'standard' | 'deep';
+
+export interface ResearchResult {
+ id: string;
+ questionId: string;
+ modelId: string;
+ provider: string;
+ researchDepth: ResearchDepth;
+ summary: string;
+ keyPoints: string[];
+ followUpQuestions: string[];
+ promptTokens?: number;
+ completionTokens?: number;
+ estimatedCost?: number;
+ createdAt: string;
+ durationMs?: number;
+ sources?: Source[];
+}
+
+export interface Source {
+ id: string;
+ researchResultId: string;
+ url: string;
+ title: string;
+ snippet?: string;
+ domain?: string;
+ extractedContent?: string;
+ contentMarkdown?: string;
+ wordCount?: number;
+ readingTime?: number;
+ relevanceScore?: number;
+ position: number;
+ engine?: string;
+ author?: string;
+ publishedDate?: string;
+ siteName?: string;
+ createdAt: string;
+}
+
+export interface Answer {
+ id: string;
+ questionId: string;
+ researchResultId?: string;
+ content: string;
+ contentMarkdown?: string;
+ summary?: string;
+ modelId: string;
+ provider: string;
+ promptTokens?: number;
+ completionTokens?: number;
+ estimatedCost?: number;
+ confidence?: number;
+ sourceCount?: number;
+ citations: Citation[];
+ rating?: number;
+ feedback?: string;
+ isAccepted: boolean;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+ durationMs?: number;
+}
+
+export interface Citation {
+ sourceId: string;
+ text: string;
+ position: number;
+}
+
+export interface CreateQuestionDto {
+ title: string;
+ description?: string;
+ collectionId?: string;
+ tags?: string[];
+ priority?: QuestionPriority;
+ researchDepth?: ResearchDepth;
+}
+
+export interface UpdateQuestionDto {
+ title?: string;
+ description?: string;
+ collectionId?: string;
+ tags?: string[];
+ priority?: QuestionPriority;
+ status?: QuestionStatus;
+ researchDepth?: ResearchDepth;
+}
+
+export interface CreateCollectionDto {
+ name: string;
+ description?: string;
+ color?: string;
+ icon?: string;
+ isDefault?: boolean;
+}
+
+export interface UpdateCollectionDto {
+ name?: string;
+ description?: string;
+ color?: string;
+ icon?: string;
+ isDefault?: boolean;
+ sortOrder?: number;
+}
+
+export interface StartResearchDto {
+ questionId: string;
+ depth?: ResearchDepth;
+ categories?: string[];
+ engines?: string[];
+ language?: string;
+ maxSources?: number;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ total: number;
+}
diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte
new file mode 100644
index 000000000..592cb5a17
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
diff --git a/apps/questions/apps/web/src/routes/(app)/+page.svelte b/apps/questions/apps/web/src/routes/(app)/+page.svelte
new file mode 100644
index 000000000..60b85e7cd
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(app)/+page.svelte
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+ {collectionsStore.selected ? collectionsStore.selected.name : 'All Questions'}
+
+
+ {questionsStore.total} question{questionsStore.total !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+ e.key === 'Enter' && handleSearch()}
+ placeholder="Search questions..."
+ class="w-full rounded-lg border border-border bg-background py-2 pl-10 pr-4 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
+ />
+
+
+
+
+
+
+
+
+ {#if questionsStore.loading}
+
+
+
+ {:else if questionsStore.questions.length === 0}
+
+
🤔
+
No questions yet
+
+ Start by asking a question and let AI research it for you.
+
+
+ Ask a Question
+
+
+ {:else}
+
+ {/if}
+
diff --git a/apps/questions/apps/web/src/routes/(app)/new/+page.svelte b/apps/questions/apps/web/src/routes/(app)/new/+page.svelte
new file mode 100644
index 000000000..00485d542
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(app)/new/+page.svelte
@@ -0,0 +1,212 @@
+
+
+
diff --git a/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte
new file mode 100644
index 000000000..5a0340811
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte
@@ -0,0 +1,316 @@
+
+
+
+ {#if loading}
+
+
+
+ {:else if error}
+
+ {error}
+
+ {:else if question}
+
+
+
+
+ Back to questions
+
+
+
+
+
{question.title}
+ {#if question.description}
+
{question.description}
+ {/if}
+
+
+
+
+ {statusLabels[question.status].label}
+
+
+
+
+ {question.researchDepth}
+
+
+
+ {#if question.tags?.length}
+ {#each question.tags as tag}
+
+ {tag}
+
+ {/each}
+ {/if}
+
+
+
+ {formatDate(question.createdAt)}
+
+
+
+
+
+
+ {#if question.status === 'open'}
+
+ {/if}
+
+
+
+
+
+ {#if researchResults.length > 0}
+
+
Research Results
+
+ {#each researchResults as result}
+
+
+
+
Summary
+
+ {result.summary}
+
+
+
+
+ {#if result.keyPoints?.length}
+
+
Key Points
+
+ {#each result.keyPoints as point}
+ - {point}
+ {/each}
+
+
+ {/if}
+
+
+ {#if result.followUpQuestions?.length}
+
+
Follow-up Questions
+
+ {#each result.followUpQuestions as followUp}
+ - {followUp}
+ {/each}
+
+
+ {/if}
+
+
+
+ Depth: {result.researchDepth}
+ {#if result.durationMs}
+ Duration: {(result.durationMs / 1000).toFixed(1)}s
+ {/if}
+ {formatDate(result.createdAt)}
+
+
+ {/each}
+
+ {:else if question.status === 'open'}
+
+
🔍
+
No research yet
+
+ Click "Start Research" to begin gathering information about this question.
+
+
+ {/if}
+
+
+ {#if sources.length > 0}
+
+
Sources ({sources.length})
+
+
+ {#each sources as source}
+
+
+
+
+
+
{source.domain}
+
+ {#if source.snippet}
+
+ {source.snippet}
+
+ {/if}
+
+ {#if source.extractedContent && expandedSources.has(source.id)}
+
+
+ {source.extractedContent.substring(0, 2000)}
+ {#if source.extractedContent.length > 2000}
+ ... (truncated)
+ {/if}
+
+
+ {/if}
+
+
+ {#if source.relevanceScore}
+
+ Score: {(source.relevanceScore * 100).toFixed(0)}%
+
+ {/if}
+ {#if source.wordCount}
+
+ {source.wordCount} words
+
+ {/if}
+ {#if source.engine}
+
+ via {source.engine}
+
+ {/if}
+
+
+
+ {#if source.extractedContent}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
diff --git a/apps/questions/apps/web/src/routes/(auth)/+layout.svelte b/apps/questions/apps/web/src/routes/(auth)/+layout.svelte
new file mode 100644
index 000000000..91578956b
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+ {@render children()}
+
+
diff --git a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte
new file mode 100644
index 000000000..97e32d1b0
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte
@@ -0,0 +1,81 @@
+
+
+
+
+
Questions
+
Sign in to your account
+
+
+
+
+
+
diff --git a/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte
new file mode 100644
index 000000000..af668cd9c
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+
Questions
+
Create your account
+
+
+ {#if needsVerification}
+
+
📧
+
Check your email
+
+ We've sent a verification link to {email}. Please check your inbox and
+ click the link to verify your account.
+
+
Back to login
+
+ {:else}
+
+
+
+ Already have an account?
+
Sign in
+
+ {/if}
+
diff --git a/apps/questions/apps/web/src/routes/+layout.svelte b/apps/questions/apps/web/src/routes/+layout.svelte
new file mode 100644
index 000000000..39771babd
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/+layout.svelte
@@ -0,0 +1,39 @@
+
+
+{#if loading}
+
+{:else}
+
+ {@render children()}
+
+{/if}
diff --git a/apps/questions/apps/web/src/routes/health/+server.ts b/apps/questions/apps/web/src/routes/health/+server.ts
new file mode 100644
index 000000000..f8ddc452f
--- /dev/null
+++ b/apps/questions/apps/web/src/routes/health/+server.ts
@@ -0,0 +1,8 @@
+import { json } from '@sveltejs/kit';
+
+export async function GET() {
+ return json({
+ status: 'ok',
+ timestamp: new Date().toISOString(),
+ });
+}
diff --git a/apps/questions/apps/web/svelte.config.js b/apps/questions/apps/web/svelte.config.js
new file mode 100644
index 000000000..a7a917e4c
--- /dev/null
+++ b/apps/questions/apps/web/svelte.config.js
@@ -0,0 +1,14 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter({
+ out: 'build',
+ }),
+ },
+};
+
+export default config;
diff --git a/apps/questions/apps/web/tsconfig.json b/apps/questions/apps/web/tsconfig.json
new file mode 100644
index 000000000..a8f10c8e3
--- /dev/null
+++ b/apps/questions/apps/web/tsconfig.json
@@ -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"
+ }
+}
diff --git a/apps/questions/apps/web/vite.config.ts b/apps/questions/apps/web/vite.config.ts
new file mode 100644
index 000000000..a51885fb7
--- /dev/null
+++ b/apps/questions/apps/web/vite.config.ts
@@ -0,0 +1,35 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ server: {
+ port: 5111,
+ strictPort: true,
+ },
+ ssr: {
+ noExternal: [
+ '@manacore/shared-icons',
+ '@manacore/shared-ui',
+ '@manacore/shared-tailwind',
+ '@manacore/shared-theme',
+ '@manacore/shared-theme-ui',
+ '@manacore/shared-auth',
+ '@manacore/shared-auth-ui',
+ '@manacore/shared-branding',
+ ],
+ },
+ optimizeDeps: {
+ exclude: [
+ '@manacore/shared-icons',
+ '@manacore/shared-ui',
+ '@manacore/shared-tailwind',
+ '@manacore/shared-theme',
+ '@manacore/shared-theme-ui',
+ '@manacore/shared-auth',
+ '@manacore/shared-auth-ui',
+ '@manacore/shared-branding',
+ ],
+ },
+});
diff --git a/package.json b/package.json
index 9a799de9f..bca50a4bb 100644
--- a/package.json
+++ b/package.json
@@ -222,7 +222,7 @@
"dev:questions:backend": "pnpm --filter @questions/backend dev",
"dev:questions:web": "pnpm --filter @questions/web dev",
"dev:questions:app": "turbo run dev --filter=@questions/web --filter=@questions/backend",
- "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend -c blue,yellow,green \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\"",
+ "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend,web -c blue,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\" \"pnpm dev:questions:web\"",
"questions:db:push": "pnpm --filter @questions/backend db:push",
"questions:db:studio": "pnpm --filter @questions/backend db:studio",
"dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev",