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
This commit is contained in:
Claude 2026-01-29 00:00:53 +00:00
parent ec96d4e952
commit f93ca53dfb
No known key found for this signature in database
32 changed files with 2399 additions and 9 deletions

View file

@ -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

View file

@ -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

View file

@ -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"
}

View file

@ -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);
}

13
apps/questions/apps/web/src/app.d.ts vendored Normal file
View file

@ -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 {};

View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6366f1" />
<meta name="description" content="Questions - AI-powered research assistant" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -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<Answer[]> {
return apiClient.get<Answer[]>(`/api/v1/answers/question/${questionId}`);
},
async getAccepted(questionId: string): Promise<Answer | null> {
return apiClient.get<Answer | null>(`/api/v1/answers/question/${questionId}/accepted`);
},
async getById(id: string): Promise<Answer> {
return apiClient.get<Answer>(`/api/v1/answers/${id}`);
},
async rate(id: string, data: RateAnswerDto): Promise<Answer> {
return apiClient.post<Answer>(`/api/v1/answers/${id}/rate`, data);
},
async accept(id: string, isAccepted: boolean): Promise<Answer> {
return apiClient.post<Answer>(`/api/v1/answers/${id}/accept`, { isAccepted });
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/api/v1/answers/${id}`);
},
};

View file

@ -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<string, string>;
}
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<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
'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<T>;
}
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,32 @@
import { apiClient } from './client';
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
export const collectionsApi = {
async getAll(): Promise<Collection[]> {
return apiClient.get<Collection[]>('/api/v1/collections');
},
async getById(id: string): Promise<Collection> {
return apiClient.get<Collection>(`/api/v1/collections/${id}`);
},
async getDefault(): Promise<Collection | null> {
return apiClient.get<Collection | null>('/api/v1/collections/default');
},
async create(data: CreateCollectionDto): Promise<Collection> {
return apiClient.post<Collection>('/api/v1/collections', data);
},
async update(id: string, data: UpdateCollectionDto): Promise<Collection> {
return apiClient.put<Collection>(`/api/v1/collections/${id}`, data);
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/api/v1/collections/${id}`);
},
async reorder(orderedIds: string[]): Promise<void> {
await apiClient.post('/api/v1/collections/reorder', { orderedIds });
},
};

View file

@ -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';

View file

@ -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<PaginatedResponse<Question>> {
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<PaginatedResponse<Question>>(
`/api/v1/questions${query ? `?${query}` : ''}`,
);
},
async getById(id: string): Promise<Question> {
return apiClient.get<Question>(`/api/v1/questions/${id}`);
},
async create(data: CreateQuestionDto): Promise<Question> {
return apiClient.post<Question>('/api/v1/questions', data);
},
async update(id: string, data: UpdateQuestionDto): Promise<Question> {
return apiClient.put<Question>(`/api/v1/questions/${id}`, data);
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/api/v1/questions/${id}`);
},
async updateStatus(id: string, status: string): Promise<Question> {
return apiClient.put<Question>(`/api/v1/questions/${id}/status`, { status });
},
};

View file

@ -0,0 +1,20 @@
import { apiClient } from './client';
import type { ResearchResult, StartResearchDto } from '$lib/types';
export const researchApi = {
async start(data: StartResearchDto): Promise<ResearchResult> {
return apiClient.post<ResearchResult>('/api/v1/research/start', data);
},
async getByQuestion(questionId: string): Promise<ResearchResult[]> {
return apiClient.get<ResearchResult[]>(`/api/v1/research/question/${questionId}`);
},
async getById(id: string): Promise<ResearchResult> {
return apiClient.get<ResearchResult>(`/api/v1/research/${id}`);
},
async checkHealth(): Promise<{ service: string; status: string }> {
return apiClient.get('/api/v1/research/health/search');
},
};

View file

@ -0,0 +1,20 @@
import { apiClient } from './client';
import type { Source } from '$lib/types';
export const sourcesApi = {
async getByResearchResult(researchResultId: string): Promise<Source[]> {
return apiClient.get<Source[]>(`/api/v1/sources/research/${researchResultId}`);
},
async getByQuestion(questionId: string): Promise<Source[]> {
return apiClient.get<Source[]>(`/api/v1/sources/question/${questionId}`);
},
async getById(id: string): Promise<Source> {
return apiClient.get<Source>(`/api/v1/sources/${id}`);
},
async getContent(id: string): Promise<{ text: string; markdown?: string }> {
return apiClient.get(`/api/v1/sources/${id}/content`);
},
};

View file

@ -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<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(),
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
getAuthService();
return _tokenManager;
}
let user = $state<UserData | null>(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<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) {
return null;
}
return await tokenManager.getValidToken();
},
};

View file

@ -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<Collection[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedId = $state<string | null>(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<Collection | null> {
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<Collection | null> {
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<boolean> {
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<boolean> {
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;
},
};

View file

@ -0,0 +1,4 @@
export { authStore } from './auth.svelte';
export { questionsStore } from './questions.svelte';
export { collectionsStore } from './collections.svelte';
export { theme } from './theme';

View file

@ -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<Question[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let currentFilters = $state<QuestionFilters>({});
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<Question | null> {
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<Question | null> {
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<boolean> {
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<Question | null> {
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 = {};
},
};

View file

@ -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);
},
};

View file

@ -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<T> {
data: T[];
total: number;
}

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore, collectionsStore, questionsStore } from '$lib/stores';
import { apiClient } from '$lib/api/client';
import {
Search,
Plus,
FolderOpen,
Settings,
LogOut,
Moon,
Sun,
HelpCircle,
ChevronRight,
} from 'lucide-svelte';
import { theme } from '$lib/stores/theme';
let { children } = $props();
let sidebarOpen = $state(true);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
// Load initial data
await collectionsStore.load();
await questionsStore.load();
});
async function handleSignOut() {
await authStore.signOut();
apiClient.setAccessToken(null);
goto('/login');
}
function selectCollection(id: string | null) {
collectionsStore.select(id);
if (id) {
questionsStore.load({ collectionId: id });
} else {
questionsStore.load();
}
}
</script>
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside
class="flex w-64 flex-col border-r border-border bg-card transition-all duration-200"
class:w-64={sidebarOpen}
class:w-16={!sidebarOpen}
>
<!-- Header -->
<div class="flex h-16 items-center justify-between border-b border-border px-4">
{#if sidebarOpen}
<h1 class="text-xl font-bold text-primary">Questions</h1>
{/if}
<button
onclick={() => (sidebarOpen = !sidebarOpen)}
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary"
>
<ChevronRight class="h-5 w-5 transition-transform" class:rotate-180={sidebarOpen} />
</button>
</div>
<!-- New Question Button -->
<div class="p-4">
<a
href="/new"
class="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
>
<Plus class="h-5 w-5" />
{#if sidebarOpen}
<span>New Question</span>
{/if}
</a>
</div>
<!-- Navigation -->
<nav class="flex-1 space-y-1 px-2">
<button
onclick={() => selectCollection(null)}
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
class:active={!collectionsStore.selectedId}
>
<HelpCircle class="h-5 w-5" />
{#if sidebarOpen}
<span>All Questions</span>
<span class="ml-auto text-xs text-muted-foreground">{questionsStore.total}</span>
{/if}
</button>
{#if sidebarOpen}
<div class="my-4 px-3 text-xs font-semibold uppercase text-muted-foreground">
Collections
</div>
{/if}
{#each collectionsStore.collections as collection}
<button
onclick={() => selectCollection(collection.id)}
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
class:active={collectionsStore.selectedId === collection.id}
>
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
{#if sidebarOpen}
<span class="truncate">{collection.name}</span>
<span class="ml-auto text-xs text-muted-foreground">{collection.questionCount || 0}</span
>
{/if}
</button>
{/each}
{#if sidebarOpen}
<a
href="/collections/new"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<Plus class="h-5 w-5" />
<span>Add Collection</span>
</a>
{/if}
</nav>
<!-- Footer -->
<div class="border-t border-border p-2">
<button
onclick={() => theme.toggle()}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
{#if theme.current === 'dark'}
<Sun class="h-5 w-5" />
{:else}
<Moon class="h-5 w-5" />
{/if}
{#if sidebarOpen}
<span>Toggle Theme</span>
{/if}
</button>
<a
href="/settings"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<Settings class="h-5 w-5" />
{#if sidebarOpen}
<span>Settings</span>
{/if}
</a>
<button
onclick={handleSignOut}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<LogOut class="h-5 w-5" />
{#if sidebarOpen}
<span>Sign Out</span>
{/if}
</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>

View file

@ -0,0 +1,183 @@
<script lang="ts">
import { questionsStore, collectionsStore } from '$lib/stores';
import { Search, Filter, Clock, CheckCircle, Loader2, Archive } from 'lucide-svelte';
import type { QuestionStatus, ResearchDepth } from '$lib/types';
let searchQuery = $state('');
let statusFilter = $state<QuestionStatus | ''>('');
const statusIcons = {
open: { icon: Clock, color: 'text-gray-500' },
researching: { icon: Loader2, color: 'text-blue-500' },
answered: { icon: CheckCircle, color: 'text-green-500' },
archived: { icon: Archive, color: 'text-gray-400' },
};
const depthLabels: Record<ResearchDepth, string> = {
quick: 'Quick',
standard: 'Standard',
deep: 'Deep',
};
async function handleSearch() {
const filters: Record<string, unknown> = {};
if (searchQuery) filters.search = searchQuery;
if (statusFilter) filters.status = statusFilter;
if (collectionsStore.selectedId) filters.collectionId = collectionsStore.selectedId;
await questionsStore.load(filters);
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
}
</script>
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">
{collectionsStore.selected ? collectionsStore.selected.name : 'All Questions'}
</h1>
<p class="mt-1 text-muted-foreground">
{questionsStore.total} question{questionsStore.total !== 1 ? 's' : ''}
</p>
</div>
<!-- Search and Filters -->
<div class="mb-6 flex gap-4">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
bind:value={searchQuery}
onkeyup={(e) => 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"
/>
</div>
<select
bind:value={statusFilter}
onchange={handleSearch}
class="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"
>
<option value="">All Status</option>
<option value="open">Open</option>
<option value="researching">Researching</option>
<option value="answered">Answered</option>
<option value="archived">Archived</option>
</select>
<button
onclick={handleSearch}
class="flex items-center gap-2 rounded-lg bg-secondary px-4 py-2 text-foreground hover:bg-secondary-hover"
>
<Filter class="h-5 w-5" />
<span>Filter</span>
</button>
</div>
<!-- Questions List -->
{#if questionsStore.loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
</div>
{:else if questionsStore.questions.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-6xl">🤔</div>
<h2 class="mb-2 text-xl font-semibold text-foreground">No questions yet</h2>
<p class="mb-4 text-muted-foreground">
Start by asking a question and let AI research it for you.
</p>
<a
href="/new"
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
>
Ask a Question
</a>
</div>
{:else}
<div class="space-y-3">
{#each questionsStore.questions as question}
{@const StatusIcon = statusIcons[question.status]?.icon || Clock}
{@const statusColor = statusIcons[question.status]?.color || 'text-gray-500'}
<a
href="/question/{question.id}"
class="question-card block rounded-xl border border-border bg-card p-4"
>
<div class="flex items-start gap-4">
<!-- Status Icon -->
<div class="mt-1">
<StatusIcon
class="h-5 w-5 {statusColor}"
class:animate-spin={question.status === 'researching'}
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h3 class="font-medium text-foreground line-clamp-2">
{question.title}
</h3>
{#if question.description}
<p class="mt-1 text-sm text-muted-foreground line-clamp-2">
{question.description}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-3">
<!-- Tags -->
{#if question.tags?.length}
<div class="flex gap-1">
{#each question.tags.slice(0, 3) as tag}
<span
class="tag-badge rounded-full bg-secondary px-2 py-0.5 text-xs text-foreground"
>
{tag}
</span>
{/each}
{#if question.tags.length > 3}
<span class="text-xs text-muted-foreground">+{question.tags.length - 3}</span>
{/if}
</div>
{/if}
<!-- Depth -->
<span class="depth-indicator depth-{question.researchDepth}">
{depthLabels[question.researchDepth]}
</span>
<!-- Date -->
<span class="text-xs text-muted-foreground">
{formatDate(question.createdAt)}
</span>
</div>
</div>
<!-- Priority Indicator -->
{#if question.priority !== 'normal'}
<div
class="priority-indicator h-full min-h-[60px] priority-{question.priority}"
></div>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,212 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { questionsStore, collectionsStore } from '$lib/stores';
import { researchApi } from '$lib/api/research';
import { ArrowLeft, Zap, Clock, Sparkles } from 'lucide-svelte';
import type { ResearchDepth, QuestionPriority } from '$lib/types';
let title = $state('');
let description = $state('');
let collectionId = $state<string | undefined>(collectionsStore.selectedId || undefined);
let tags = $state<string[]>([]);
let tagInput = $state('');
let priority = $state<QuestionPriority>('normal');
let researchDepth = $state<ResearchDepth>('standard');
let startResearch = $state(true);
let loading = $state(false);
let error = $state<string | null>(null);
const depthOptions: { value: ResearchDepth; label: string; description: string; icon: typeof Zap }[] = [
{ value: 'quick', label: 'Quick', description: '5 sources, fast results', icon: Zap },
{ value: 'standard', label: 'Standard', description: '15 sources, balanced', icon: Clock },
{ value: 'deep', label: 'Deep', description: '30+ sources, comprehensive', icon: Sparkles },
];
function addTag() {
const tag = tagInput.trim().toLowerCase();
if (tag && !tags.includes(tag)) {
tags = [...tags, tag];
}
tagInput = '';
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) {
error = 'Please enter a question';
return;
}
loading = true;
error = null;
const question = await questionsStore.create({
title: title.trim(),
description: description.trim() || undefined,
collectionId,
tags,
priority,
researchDepth,
});
if (question) {
if (startResearch) {
// Start research in the background
researchApi.start({ questionId: question.id, depth: researchDepth }).catch(console.error);
}
goto(`/question/${question.id}`);
} else {
error = questionsStore.error || 'Failed to create question';
loading = false;
}
}
</script>
<div class="mx-auto max-w-2xl p-6">
<!-- Header -->
<div class="mb-6">
<a
href="/"
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="h-4 w-4" />
Back to questions
</a>
<h1 class="text-2xl font-bold text-foreground">Ask a Question</h1>
<p class="mt-1 text-muted-foreground">
Enter your question and let AI research it for you
</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Question Title -->
<div>
<label for="title" class="mb-2 block font-medium text-foreground">Your Question</label>
<input
type="text"
id="title"
bind:value={title}
placeholder="What would you like to know?"
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-lg text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-2 block font-medium text-foreground">
Additional Context <span class="text-muted-foreground">(optional)</span>
</label>
<textarea
id="description"
bind:value={description}
placeholder="Provide any additional details or context..."
rows="3"
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
></textarea>
</div>
<!-- Collection -->
<div>
<label for="collection" class="mb-2 block font-medium text-foreground">Collection</label>
<select
id="collection"
bind:value={collectionId}
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"
>
<option value={undefined}>No collection</option>
{#each collectionsStore.collections as collection}
<option value={collection.id}>{collection.name}</option>
{/each}
</select>
</div>
<!-- Tags -->
<div>
<label for="tags" class="mb-2 block font-medium text-foreground">Tags</label>
<div class="flex flex-wrap gap-2 mb-2">
{#each tags as tag}
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-3 py-1 text-sm">
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="ml-1 text-muted-foreground hover:text-foreground"
>
×
</button>
</span>
{/each}
</div>
<input
type="text"
id="tags"
bind:value={tagInput}
onkeydown={(e) => 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"
/>
</div>
<!-- Research Depth -->
<div>
<label class="mb-2 block font-medium text-foreground">Research Depth</label>
<div class="grid grid-cols-3 gap-3">
{#each depthOptions as option}
<button
type="button"
onclick={() => (researchDepth = option.value)}
class="rounded-lg border-2 p-4 text-left transition-all {researchDepth === option.value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'}"
>
<svelte:component this={option.icon} class="mb-2 h-5 w-5 text-primary" />
<div class="font-medium text-foreground">{option.label}</div>
<div class="mt-1 text-xs text-muted-foreground">{option.description}</div>
</button>
{/each}
</div>
</div>
<!-- Start Research Toggle -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="startResearch"
bind:checked={startResearch}
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
/>
<label for="startResearch" class="text-foreground">
Start research immediately after creating
</label>
</div>
<!-- Submit -->
<div class="flex gap-3">
<a
href="/"
class="flex-1 rounded-lg border border-border px-4 py-3 text-center font-medium text-foreground hover:bg-secondary"
>
Cancel
</a>
<button
type="submit"
disabled={loading || !title.trim()}
class="flex-1 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Creating...' : 'Ask Question'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,316 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { questionsApi } from '$lib/api/questions';
import { researchApi } from '$lib/api/research';
import { sourcesApi } from '$lib/api/sources';
import {
ArrowLeft,
Clock,
Loader2,
CheckCircle,
Archive,
Play,
ExternalLink,
ChevronDown,
ChevronUp,
} from 'lucide-svelte';
import type { Question, ResearchResult, Source } from '$lib/types';
let question = $state<Question | null>(null);
let researchResults = $state<ResearchResult[]>([]);
let sources = $state<Source[]>([]);
let loading = $state(true);
let researchLoading = $state(false);
let error = $state<string | null>(null);
let expandedSources = $state<Set<string>>(new Set());
const statusLabels = {
open: { label: 'Open', color: 'bg-gray-100 text-gray-700' },
researching: { label: 'Researching', color: 'bg-blue-100 text-blue-700' },
answered: { label: 'Answered', color: 'bg-green-100 text-green-700' },
archived: { label: 'Archived', color: 'bg-gray-100 text-gray-500' },
};
onMount(async () => {
await loadQuestion();
});
async function loadQuestion() {
loading = true;
error = null;
try {
const id = page.params.id;
question = await questionsApi.getById(id);
researchResults = await researchApi.getByQuestion(id);
sources = await sourcesApi.getByQuestion(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load question';
} finally {
loading = false;
}
}
async function startResearch() {
if (!question) return;
researchLoading = true;
error = null;
try {
const result = await researchApi.start({
questionId: question.id,
depth: question.researchDepth,
});
researchResults = [result, ...researchResults];
sources = await sourcesApi.getByQuestion(question.id);
// Reload question to get updated status
question = await questionsApi.getById(question.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start research';
} finally {
researchLoading = false;
}
}
function toggleSource(id: string) {
if (expandedSources.has(id)) {
expandedSources.delete(id);
expandedSources = new Set(expandedSources);
} else {
expandedSources.add(id);
expandedSources = new Set(expandedSources);
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<div class="p-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
</div>
{:else if error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">
{error}
</div>
{:else if question}
<!-- Header -->
<div class="mb-6">
<a
href="/"
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="h-4 w-4" />
Back to questions
</a>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h1 class="text-2xl font-bold text-foreground">{question.title}</h1>
{#if question.description}
<p class="mt-2 text-muted-foreground">{question.description}</p>
{/if}
<div class="mt-4 flex flex-wrap items-center gap-3">
<!-- Status Badge -->
<span
class="rounded-full px-3 py-1 text-sm font-medium {statusLabels[question.status]
.color}"
>
{statusLabels[question.status].label}
</span>
<!-- Depth -->
<span class="depth-indicator depth-{question.researchDepth}">
{question.researchDepth}
</span>
<!-- Tags -->
{#if question.tags?.length}
{#each question.tags as tag}
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-foreground">
{tag}
</span>
{/each}
{/if}
<!-- Date -->
<span class="text-sm text-muted-foreground">
{formatDate(question.createdAt)}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
{#if question.status === 'open'}
<button
onclick={startResearch}
disabled={researchLoading}
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
>
{#if researchLoading}
<Loader2 class="h-5 w-5 animate-spin" />
Researching...
{:else}
<Play class="h-5 w-5" />
Start Research
{/if}
</button>
{/if}
</div>
</div>
</div>
<!-- Research Results -->
{#if researchResults.length > 0}
<div class="mb-8">
<h2 class="mb-4 text-lg font-semibold text-foreground">Research Results</h2>
{#each researchResults as result}
<div class="mb-6 rounded-xl border border-border bg-card p-6">
<!-- Summary -->
<div class="mb-4">
<h3 class="mb-2 font-medium text-foreground">Summary</h3>
<div class="markdown-content text-foreground whitespace-pre-wrap">
{result.summary}
</div>
</div>
<!-- Key Points -->
{#if result.keyPoints?.length}
<div class="mb-4">
<h3 class="mb-2 font-medium text-foreground">Key Points</h3>
<ul class="list-disc space-y-1 pl-5 text-foreground">
{#each result.keyPoints as point}
<li>{point}</li>
{/each}
</ul>
</div>
{/if}
<!-- Follow-up Questions -->
{#if result.followUpQuestions?.length}
<div class="mb-4">
<h3 class="mb-2 font-medium text-foreground">Follow-up Questions</h3>
<ul class="space-y-2">
{#each result.followUpQuestions as followUp}
<li class="text-muted-foreground">{followUp}</li>
{/each}
</ul>
</div>
{/if}
<!-- Meta -->
<div class="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<span>Depth: {result.researchDepth}</span>
{#if result.durationMs}
<span>Duration: {(result.durationMs / 1000).toFixed(1)}s</span>
{/if}
<span>{formatDate(result.createdAt)}</span>
</div>
</div>
{/each}
</div>
{:else if question.status === 'open'}
<div class="mb-8 rounded-xl border border-dashed border-border p-8 text-center">
<div class="mb-4 text-4xl">🔍</div>
<h2 class="mb-2 text-lg font-semibold text-foreground">No research yet</h2>
<p class="mb-4 text-muted-foreground">
Click "Start Research" to begin gathering information about this question.
</p>
</div>
{/if}
<!-- Sources -->
{#if sources.length > 0}
<div>
<h2 class="mb-4 text-lg font-semibold text-foreground">Sources ({sources.length})</h2>
<div class="space-y-3">
{#each sources as source}
<div class="source-card rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">#{source.position}</span>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
class="font-medium text-foreground hover:text-primary"
>
{source.title}
</a>
<ExternalLink class="h-4 w-4 text-muted-foreground" />
</div>
<p class="mt-1 text-sm text-muted-foreground">{source.domain}</p>
{#if source.snippet}
<p class="mt-2 text-sm text-foreground line-clamp-2">
{source.snippet}
</p>
{/if}
{#if source.extractedContent && expandedSources.has(source.id)}
<div class="mt-4 rounded-lg bg-secondary/50 p-4">
<div class="markdown-content text-sm text-foreground whitespace-pre-wrap">
{source.extractedContent.substring(0, 2000)}
{#if source.extractedContent.length > 2000}
<span class="text-muted-foreground">... (truncated)</span>
{/if}
</div>
</div>
{/if}
<div class="mt-3 flex items-center gap-4">
{#if source.relevanceScore}
<span class="text-xs text-muted-foreground">
Score: {(source.relevanceScore * 100).toFixed(0)}%
</span>
{/if}
{#if source.wordCount}
<span class="text-xs text-muted-foreground">
{source.wordCount} words
</span>
{/if}
{#if source.engine}
<span class="text-xs text-muted-foreground">
via {source.engine}
</span>
{/if}
</div>
</div>
{#if source.extractedContent}
<button
onclick={() => toggleSource(source.id)}
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
{#if expandedSources.has(source.id)}
<ChevronUp class="h-5 w-5" />
{:else}
<ChevronDown class="h-5 w-5" />
{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
$effect(() => {
if (authStore.initialized && authStore.isAuthenticated) {
goto('/');
}
});
</script>
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary/5 to-accent/5">
<div class="w-full max-w-md px-4">
{@render children()}
</div>
</div>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
const result = await authStore.signIn(email, password);
if (result.success) {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
goto('/');
} else {
error = result.error || 'Login failed';
}
loading = false;
}
</script>
<div class="rounded-xl bg-card p-8 shadow-lg">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
<p class="mt-2 text-muted-foreground">Sign in to your account</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
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"
placeholder="you@example.com"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
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"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div class="mt-6 text-center text-sm text-muted-foreground">
<a href="/forgot-password" class="text-primary hover:underline">Forgot password?</a>
<span class="mx-2">·</span>
<a href="/register" class="text-primary hover:underline">Create account</a>
</div>
</div>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
let needsVerification = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 8) {
error = 'Password must be at least 8 characters';
return;
}
loading = true;
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
needsVerification = true;
} else {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
goto('/');
}
} else {
error = result.error || 'Registration failed';
}
loading = false;
}
</script>
<div class="rounded-xl bg-card p-8 shadow-lg">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
<p class="mt-2 text-muted-foreground">Create your account</p>
</div>
{#if needsVerification}
<div class="text-center">
<div class="mb-4 text-4xl">📧</div>
<h2 class="mb-2 text-lg font-semibold">Check your email</h2>
<p class="text-muted-foreground">
We've sent a verification link to <strong>{email}</strong>. Please check your inbox and
click the link to verify your account.
</p>
<a href="/login" class="mt-4 inline-block text-primary hover:underline">Back to login</a>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
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"
placeholder="you@example.com"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label
>
<input
type="password"
id="password"
bind:value={password}
required
minlength="8"
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"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="mb-1 block text-sm font-medium text-foreground"
>Confirm Password</label
>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
required
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"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<div class="mt-6 text-center text-sm text-muted-foreground">
Already have an account?
<a href="/login" class="text-primary hover:underline">Sign in</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
theme.initialize();
await authStore.initialize();
// Set token in API client when authenticated
if (authStore.isAuthenticated) {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
}
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="flex flex-col items-center gap-4">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
<p class="text-muted-foreground">Loading...</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
export async function GET() {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
});
}

View file

@ -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;

View file

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

View file

@ -0,0 +1,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',
],
},
});

View file

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