mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +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}
|
||||
<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"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
<span>Add Collection</span>
|
||||
<span>Manage Collections</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { questionsStore, collectionsStore } from '$lib/stores';
|
||||
import { QuestionSkeleton, ErrorAlert } from '$lib/components';
|
||||
import { Search, Filter, Clock, CheckCircle, Loader2, Archive } from 'lucide-svelte';
|
||||
import type { QuestionStatus, ResearchDepth } from '$lib/types';
|
||||
|
||||
|
|
@ -90,11 +91,20 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if questionsStore.error}
|
||||
<div class="mb-6">
|
||||
<ErrorAlert
|
||||
message={questionsStore.error}
|
||||
onRetry={() => questionsStore.load(questionsStore.filters)}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
<QuestionSkeleton count={5} />
|
||||
{:else if questionsStore.questions.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<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 { researchApi } from '$lib/api/research';
|
||||
import { sourcesApi } from '$lib/api/sources';
|
||||
import { QuestionDetailSkeleton, ErrorAlert } from '$lib/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
|
|
@ -95,16 +96,14 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<QuestionDetailSkeleton />
|
||||
{:else if error}
|
||||
<div class="p-6">
|
||||
<ErrorAlert message={error} onRetry={loadQuestion} />
|
||||
</div>
|
||||
{:else if question}
|
||||
<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
|
||||
|
|
@ -312,5 +311,5 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</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 { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -24,14 +25,7 @@
|
|||
</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>
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue