mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(questions): add production-ready pages and components
- Add forgot-password page with email recovery flow - Add settings page with account, theme, notifications, privacy options - Add collections management page with create/edit/delete functionality - Add CollectionModal component for collection CRUD - Add ErrorAlert component for consistent error handling - Add loading skeletons (QuestionSkeleton, QuestionDetailSkeleton, AppLoadingSkeleton) - Update layouts and pages to use skeletons and error handling https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r
This commit is contained in:
parent
f93ca53dfb
commit
928cac6799
14 changed files with 845 additions and 23 deletions
|
|
@ -0,0 +1,218 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
import { collectionsStore } from '$lib/stores';
|
||||||
|
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection?: Collection | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (collection: Collection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection = null, onClose, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let name = $state(collection?.name || '');
|
||||||
|
let description = $state(collection?.description || '');
|
||||||
|
let color = $state(collection?.color || '#6366f1');
|
||||||
|
let icon = $state(collection?.icon || 'folder');
|
||||||
|
let isDefault = $state(collection?.isDefault || false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
'#8b5cf6', // Violet
|
||||||
|
'#ec4899', // Pink
|
||||||
|
'#ef4444', // Red
|
||||||
|
'#f97316', // Orange
|
||||||
|
'#eab308', // Yellow
|
||||||
|
'#22c55e', // Green
|
||||||
|
'#14b8a6', // Teal
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
'#6b7280', // Gray
|
||||||
|
];
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
'folder',
|
||||||
|
'star',
|
||||||
|
'heart',
|
||||||
|
'bookmark',
|
||||||
|
'lightbulb',
|
||||||
|
'rocket',
|
||||||
|
'code',
|
||||||
|
'book',
|
||||||
|
'briefcase',
|
||||||
|
'globe',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
error = 'Name is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let saved: Collection | null;
|
||||||
|
|
||||||
|
if (collection) {
|
||||||
|
const data: UpdateCollectionDto = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
isDefault,
|
||||||
|
};
|
||||||
|
saved = await collectionsStore.update(collection.id, data);
|
||||||
|
} else {
|
||||||
|
const data: CreateCollectionDto = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
isDefault,
|
||||||
|
};
|
||||||
|
saved = await collectionsStore.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
onSave(saved);
|
||||||
|
} else {
|
||||||
|
error = collectionsStore.error || 'Failed to save collection';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to save collection';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-card shadow-xl" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">
|
||||||
|
{collection ? 'Edit Collection' : 'New Collection'}
|
||||||
|
</h2>
|
||||||
|
<button onclick={onClose} class="rounded-lg p-2 text-muted-foreground hover:bg-secondary">
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form onsubmit={handleSubmit} class="p-6">
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="Collection name"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows="2"
|
||||||
|
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"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-foreground">Color</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each colors as c}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (color = c)}
|
||||||
|
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {color === c
|
||||||
|
? 'border-foreground scale-110'
|
||||||
|
: 'border-transparent'}"
|
||||||
|
style="background-color: {c}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-foreground">Icon</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each icons as i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (icon = i)}
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 text-sm transition-all {icon ===
|
||||||
|
i
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
{i.charAt(0).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={isDefault}
|
||||||
|
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-foreground">Set as default collection</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="flex-1 rounded-lg border border-border px-4 py-2 font-medium text-foreground hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="flex-1 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : collection ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
39
apps/questions/apps/web/src/lib/components/ErrorAlert.svelte
Normal file
39
apps/questions/apps/web/src/lib/components/ErrorAlert.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { AlertCircle, X, RefreshCw } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, onDismiss, onRetry }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<AlertCircle class="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-destructive">Error</p>
|
||||||
|
<p class="mt-1 text-sm text-foreground">{message}</p>
|
||||||
|
|
||||||
|
{#if onRetry}
|
||||||
|
<button
|
||||||
|
onclick={onRetry}
|
||||||
|
class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onDismiss}
|
||||||
|
<button onclick={onDismiss} class="text-muted-foreground hover:text-foreground">
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
3
apps/questions/apps/web/src/lib/components/index.ts
Normal file
3
apps/questions/apps/web/src/lib/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as CollectionModal } from './CollectionModal.svelte';
|
||||||
|
export { default as ErrorAlert } from './ErrorAlert.svelte';
|
||||||
|
export * from './skeletons';
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<div class="flex min-h-screen animate-pulse">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 border-r border-border bg-card">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex h-16 items-center border-b border-border px-4">
|
||||||
|
<div class="h-6 w-28 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Question Button -->
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="space-y-1 px-2">
|
||||||
|
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||||
|
|
||||||
|
<div class="my-4 px-3">
|
||||||
|
<div class="h-3 w-20 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="h-8 w-48 rounded bg-muted"></div>
|
||||||
|
<div class="mt-2 h-4 w-32 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 flex gap-4">
|
||||||
|
<div class="h-10 flex-1 rounded-lg bg-muted"></div>
|
||||||
|
<div class="h-10 w-32 rounded-lg bg-muted"></div>
|
||||||
|
<div class="h-10 w-24 rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Array(5) as _}
|
||||||
|
<div class="h-24 rounded-xl bg-muted"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<div class="p-6 animate-pulse">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="mb-4 h-4 w-32 rounded bg-muted"></div>
|
||||||
|
<div class="h-8 w-3/4 rounded bg-muted"></div>
|
||||||
|
<div class="mt-2 h-5 w-1/2 rounded bg-muted"></div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<div class="h-6 w-24 rounded-full bg-muted"></div>
|
||||||
|
<div class="h-6 w-20 rounded-full bg-muted"></div>
|
||||||
|
<div class="h-6 w-16 rounded-full bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Research Results -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="mb-4 h-6 w-40 rounded bg-muted"></div>
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6">
|
||||||
|
<div class="mb-4 h-5 w-24 rounded bg-muted"></div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="h-4 w-full rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-full rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-3/4 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 mb-4 h-5 w-28 rounded bg-muted"></div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="h-4 w-2/3 rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-1/2 rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-3/4 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-4 h-6 w-32 rounded bg-muted"></div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="rounded-lg border border-border bg-card p-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 w-3/4 rounded bg-muted"></div>
|
||||||
|
<div class="mt-1 h-4 w-1/4 rounded bg-muted"></div>
|
||||||
|
<div class="mt-2 h-4 w-full rounded bg-muted"></div>
|
||||||
|
<div class="mt-3 flex gap-4">
|
||||||
|
<div class="h-4 w-16 rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-20 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { count = 5 }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Array(count) as _, i}
|
||||||
|
<div class="rounded-xl border border-border bg-card p-4 animate-pulse">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Status Icon -->
|
||||||
|
<div class="mt-1">
|
||||||
|
<div class="h-5 w-5 rounded-full bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 w-3/4 rounded bg-muted"></div>
|
||||||
|
<div class="mt-2 h-4 w-1/2 rounded bg-muted"></div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<div class="h-5 w-16 rounded-full bg-muted"></div>
|
||||||
|
<div class="h-5 w-16 rounded-full bg-muted"></div>
|
||||||
|
<div class="h-4 w-20 rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as QuestionSkeleton } from './QuestionSkeleton.svelte';
|
||||||
|
export { default as QuestionDetailSkeleton } from './QuestionDetailSkeleton.svelte';
|
||||||
|
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||||
|
|
@ -119,11 +119,11 @@
|
||||||
|
|
||||||
{#if sidebarOpen}
|
{#if sidebarOpen}
|
||||||
<a
|
<a
|
||||||
href="/collections/new"
|
href="/collections"
|
||||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
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" />
|
<Plus class="h-5 w-5" />
|
||||||
<span>Add Collection</span>
|
<span>Manage Collections</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { questionsStore, collectionsStore } from '$lib/stores';
|
import { questionsStore, collectionsStore } from '$lib/stores';
|
||||||
|
import { QuestionSkeleton, ErrorAlert } from '$lib/components';
|
||||||
import { Search, Filter, Clock, CheckCircle, Loader2, Archive } from 'lucide-svelte';
|
import { Search, Filter, Clock, CheckCircle, Loader2, Archive } from 'lucide-svelte';
|
||||||
import type { QuestionStatus, ResearchDepth } from '$lib/types';
|
import type { QuestionStatus, ResearchDepth } from '$lib/types';
|
||||||
|
|
||||||
|
|
@ -90,11 +91,20 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
{#if questionsStore.error}
|
||||||
|
<div class="mb-6">
|
||||||
|
<ErrorAlert
|
||||||
|
message={questionsStore.error}
|
||||||
|
onRetry={() => questionsStore.load(questionsStore.filters)}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Questions List -->
|
<!-- Questions List -->
|
||||||
{#if questionsStore.loading}
|
{#if questionsStore.loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<QuestionSkeleton count={5} />
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
{:else if questionsStore.questions.length === 0}
|
{:else if questionsStore.questions.length === 0}
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<div class="mb-4 text-6xl">🤔</div>
|
<div class="mb-4 text-6xl">🤔</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { collectionsStore } from '$lib/stores';
|
||||||
|
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
||||||
|
import { ArrowLeft, Plus, Edit2, Trash2, FolderOpen, GripVertical } from 'lucide-svelte';
|
||||||
|
import type { Collection } from '$lib/types';
|
||||||
|
|
||||||
|
let showModal = $state(false);
|
||||||
|
let editingCollection = $state<Collection | null>(null);
|
||||||
|
let deleteConfirm = $state<string | null>(null);
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
editingCollection = null;
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(collection: Collection) {
|
||||||
|
editingCollection = collection;
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal = false;
|
||||||
|
editingCollection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
const success = await collectionsStore.delete(id);
|
||||||
|
if (success) {
|
||||||
|
deleteConfirm = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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">Collections</h1>
|
||||||
|
<p class="mt-1 text-muted-foreground">
|
||||||
|
Organize your questions into collections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={openCreateModal}
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
New Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collections List -->
|
||||||
|
{#if collectionsStore.collections.length === 0}
|
||||||
|
<div class="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 collections yet</h2>
|
||||||
|
<p class="mb-4 text-muted-foreground">
|
||||||
|
Create your first collection to organize your questions.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={openCreateModal}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
Create Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each collectionsStore.collections as collection}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<!-- Drag Handle -->
|
||||||
|
<div class="cursor-grab text-muted-foreground">
|
||||||
|
<GripVertical class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon & Color -->
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||||
|
style="background-color: {collection.color}20"
|
||||||
|
>
|
||||||
|
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="font-medium text-foreground">{collection.name}</h3>
|
||||||
|
{#if collection.isDefault}
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if collection.description}
|
||||||
|
<p class="mt-0.5 text-sm text-muted-foreground truncate">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{collection.questionCount || 0} questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(collection)}
|
||||||
|
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if deleteConfirm === collection.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(collection.id)}
|
||||||
|
class="rounded-lg bg-destructive px-3 py-1 text-sm text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (deleteConfirm = null)}
|
||||||
|
class="rounded-lg border border-border px-3 py-1 text-sm text-foreground hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (deleteConfirm = collection.id)}
|
||||||
|
class="rounded-lg p-2 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
{#if showModal}
|
||||||
|
<CollectionModal collection={editingCollection} onClose={closeModal} onSave={handleSave} />
|
||||||
|
{/if}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { questionsApi } from '$lib/api/questions';
|
import { questionsApi } from '$lib/api/questions';
|
||||||
import { researchApi } from '$lib/api/research';
|
import { researchApi } from '$lib/api/research';
|
||||||
import { sourcesApi } from '$lib/api/sources';
|
import { sourcesApi } from '$lib/api/sources';
|
||||||
|
import { QuestionDetailSkeleton, ErrorAlert } from '$lib/components';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -95,16 +96,14 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<QuestionDetailSkeleton />
|
||||||
|
{:else if error}
|
||||||
|
<div class="p-6">
|
||||||
|
<ErrorAlert message={error} onRetry={loadQuestion} />
|
||||||
|
</div>
|
||||||
|
{:else if question}
|
||||||
<div class="p-6">
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
|
|
@ -312,5 +311,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
178
apps/questions/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
178
apps/questions/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { ArrowLeft, User, Moon, Sun, Monitor, Bell, Shield, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let currentTheme = $state(theme.current);
|
||||||
|
let deleteConfirm = $state(false);
|
||||||
|
|
||||||
|
function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
||||||
|
theme.set(newTheme);
|
||||||
|
currentTheme = newTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: 'light', label: 'Light', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', icon: Monitor },
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<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">Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
|
<User class="h-5 w-5" />
|
||||||
|
Account
|
||||||
|
</h2>
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-muted-foreground">Email</label>
|
||||||
|
<p class="font-medium text-foreground">{authStore.user?.email || 'Not signed in'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-muted-foreground">User ID</label>
|
||||||
|
<p class="font-mono text-sm text-foreground">{authStore.user?.id || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Appearance Section -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
|
<Moon class="h-5 w-5" />
|
||||||
|
Appearance
|
||||||
|
</h2>
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6">
|
||||||
|
<label class="mb-3 block text-sm font-medium text-foreground">Theme</label>
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
{#each themeOptions as option}
|
||||||
|
<button
|
||||||
|
onclick={() => setTheme(option.value)}
|
||||||
|
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-all {currentTheme ===
|
||||||
|
option.value
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
<svelte:component this={option.icon} class="h-6 w-6" />
|
||||||
|
<span class="text-sm font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notifications Section -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
|
<Bell class="h-5 w-5" />
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-foreground">Research Complete</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Get notified when research is finished</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-foreground">Weekly Summary</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Receive a weekly summary of your questions</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Privacy Section -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
|
<Shield class="h-5 w-5" />
|
||||||
|
Privacy & Data
|
||||||
|
</h2>
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-foreground">Export Data</p>
|
||||||
|
<p class="mb-2 text-sm text-muted-foreground">
|
||||||
|
Download all your questions and research data
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Export as JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-border" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-destructive">Delete Account</p>
|
||||||
|
<p class="mb-2 text-sm text-muted-foreground">
|
||||||
|
Permanently delete your account and all data
|
||||||
|
</p>
|
||||||
|
{#if deleteConfirm}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (deleteConfirm = false)}
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (deleteConfirm = true)}
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-destructive px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="rounded-xl border border-border bg-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Questions App v1.0.0
|
||||||
|
<br />
|
||||||
|
Powered by mana-search
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let success = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = null;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const result = await authStore.resetPassword(email);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Failed to send reset email';
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Reset Password</h1>
|
||||||
|
<p class="mt-2 text-muted-foreground">Enter your email to receive a reset link</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4 text-4xl">📧</div>
|
||||||
|
<h2 class="mb-2 text-lg font-semibold text-foreground">Check your email</h2>
|
||||||
|
<p class="mb-4 text-muted-foreground">
|
||||||
|
We've sent a password reset link to <strong>{email}</strong>. Please check your inbox.
|
||||||
|
</p>
|
||||||
|
<a href="/login" class="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>
|
||||||
|
|
||||||
|
<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 ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { apiClient } from '$lib/api/client';
|
import { apiClient } from '$lib/api/client';
|
||||||
|
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -24,14 +25,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
<AppLoadingSkeleton />
|
||||||
<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}
|
{:else}
|
||||||
<div class="min-h-screen bg-background text-foreground">
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue