From f93ca53dfbd3dbe3105da7f5f3528655ac0936a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 00:00:53 +0000 Subject: [PATCH] feat(questions): implement SvelteKit web app Complete web app implementation with Svelte 5 runes: Features: - Authentication: Login, register pages with mana-core-auth integration - Question List: Filterable list with search, status, and collection filters - Question Detail: View research results and sources - New Question: Create questions with depth selection and auto-research - Collections Sidebar: Navigate and organize questions by collection - Dark Mode: Full theme support with toggle Structure: - src/lib/api/: API clients for all backend endpoints - src/lib/stores/: Svelte 5 reactive stores (auth, questions, collections) - src/lib/types/: TypeScript interfaces - src/routes/(app)/: Protected app routes - src/routes/(auth)/: Public auth routes Configuration: - Port: 5111 - Tailwind CSS with shared theme - lucide-svelte icons Also updated: - CLAUDE.md: Added web app documentation - package.json: Updated dev:questions:full to include web https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- apps/questions/CLAUDE.md | 26 +- apps/questions/apps/web/.env.example | 7 + apps/questions/apps/web/package.json | 48 +++ apps/questions/apps/web/src/app.css | 185 ++++++++++ apps/questions/apps/web/src/app.d.ts | 13 + apps/questions/apps/web/src/app.html | 14 + .../questions/apps/web/src/lib/api/answers.ts | 33 ++ apps/questions/apps/web/src/lib/api/client.ts | 97 ++++++ .../apps/web/src/lib/api/collections.ts | 32 ++ apps/questions/apps/web/src/lib/api/index.ts | 6 + .../apps/web/src/lib/api/questions.ts | 53 +++ .../apps/web/src/lib/api/research.ts | 20 ++ .../questions/apps/web/src/lib/api/sources.ts | 20 ++ .../apps/web/src/lib/stores/auth.svelte.ts | 186 +++++++++++ .../web/src/lib/stores/collections.svelte.ts | 119 +++++++ .../apps/web/src/lib/stores/index.ts | 4 + .../web/src/lib/stores/questions.svelte.ts | 116 +++++++ .../apps/web/src/lib/stores/theme.ts | 61 ++++ .../questions/apps/web/src/lib/types/index.ts | 148 ++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 173 ++++++++++ .../apps/web/src/routes/(app)/+page.svelte | 183 ++++++++++ .../web/src/routes/(app)/new/+page.svelte | 212 ++++++++++++ .../routes/(app)/question/[id]/+page.svelte | 316 ++++++++++++++++++ .../apps/web/src/routes/(auth)/+layout.svelte | 18 + .../web/src/routes/(auth)/login/+page.svelte | 81 +++++ .../src/routes/(auth)/register/+page.svelte | 125 +++++++ .../apps/web/src/routes/+layout.svelte | 39 +++ .../apps/web/src/routes/health/+server.ts | 8 + apps/questions/apps/web/svelte.config.js | 14 + apps/questions/apps/web/tsconfig.json | 14 + apps/questions/apps/web/vite.config.ts | 35 ++ package.json | 2 +- 32 files changed, 2399 insertions(+), 9 deletions(-) create mode 100644 apps/questions/apps/web/.env.example create mode 100644 apps/questions/apps/web/package.json create mode 100644 apps/questions/apps/web/src/app.css create mode 100644 apps/questions/apps/web/src/app.d.ts create mode 100644 apps/questions/apps/web/src/app.html create mode 100644 apps/questions/apps/web/src/lib/api/answers.ts create mode 100644 apps/questions/apps/web/src/lib/api/client.ts create mode 100644 apps/questions/apps/web/src/lib/api/collections.ts create mode 100644 apps/questions/apps/web/src/lib/api/index.ts create mode 100644 apps/questions/apps/web/src/lib/api/questions.ts create mode 100644 apps/questions/apps/web/src/lib/api/research.ts create mode 100644 apps/questions/apps/web/src/lib/api/sources.ts create mode 100644 apps/questions/apps/web/src/lib/stores/auth.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/collections.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/index.ts create mode 100644 apps/questions/apps/web/src/lib/stores/questions.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/theme.ts create mode 100644 apps/questions/apps/web/src/lib/types/index.ts create mode 100644 apps/questions/apps/web/src/routes/(app)/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/new/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/login/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/register/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/health/+server.ts create mode 100644 apps/questions/apps/web/svelte.config.js create mode 100644 apps/questions/apps/web/tsconfig.json create mode 100644 apps/questions/apps/web/vite.config.ts 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 @@ + + +
+ +
+ + + Back to questions + +

Ask a Question

+

+ Enter your question and let AI research it for you +

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each tags as tag} + + {tag} + + + {/each} +
+ e.key === 'Enter' && (e.preventDefault(), addTag())} + placeholder="Add a tag and press Enter" + class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+ +
+ {#each depthOptions as option} + + {/each} +
+
+ + +
+ + +
+ + +
+ + Cancel + + +
+
+
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.position} + + {source.title} + + +
+ +

{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

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ +
+ + +
+ + +
+ + +
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} +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ 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} +
+
+
+

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",