feat: add notes UI components and routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:13:22 -05:00
parent da73b25b39
commit edbfe02ee0
11 changed files with 1068 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { setNoteService } from '$lib/context/notes.svelte';
// Initialize note service for all notes routes
setNoteService('default-user');
let { children } = $props();
</script>
{@render children()}
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNoteService } from '$lib/context/notes.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Plus, LayoutGrid, List } from '@lucide/svelte';
import NotesGrid from './NotesGrid.svelte';
import NoteSearchBar from './NoteSearchBar.svelte';
// Get note service from context
const noteService = getNoteService();
let searchQuery = $state('');
let currentFilter = $state<'all' | 'pinned' | 'archived' | 'trash'>('all');
let view = $state<'grid' | 'list'>('grid');
// Get filtered notes based on current tab
const filteredNotes = $derived(() => {
let notes = noteService.activeNotes;
switch (currentFilter) {
case 'pinned':
notes = noteService.pinnedNotes;
break;
case 'archived':
notes = noteService.archivedNotes;
break;
case 'trash':
notes = noteService.trashedNotes;
break;
}
// Apply search filter
if (searchQuery.trim()) {
return noteService.searchNotes(searchQuery, notes);
}
return notes;
});
async function createNewNote() {
const note = await noteService.createNote({
title: '',
content: '',
tags: new Set()
});
// Wait a bit for the subscription to update
await new Promise((resolve) => setTimeout(resolve, 100));
goto(`/notes/${note.id}`);
}
function openNote(noteId: string) {
goto(`/notes/${noteId}`);
}
</script>
<div class="w-full container mx-auto p-6 space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight text-foreground">Notes</h1>
<p class="text-muted-foreground">Capture your thoughts with markdown</p>
</div>
<div class="flex items-center gap-3">
<!-- View Toggle -->
<div class="flex items-center border rounded-md">
<Button
variant={view === 'grid' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'grid')}
aria-label="Grid view"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant={view === 'list' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'list')}
aria-label="List view"
>
<List class="h-4 w-4" />
</Button>
</div>
<!-- New Note Button -->
<Button onclick={createNewNote} class="gap-2">
<Plus class="h-4 w-4" />
New Note
</Button>
</div>
</div>
<Separator />
<!-- Search and Filters -->
<div class="space-y-4">
<NoteSearchBar bind:value={searchQuery} />
<Tabs.Root value={currentFilter} onValueChange={(v) => (currentFilter = v as any)}>
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="all">
All
{#if !searchQuery}
<span class="ml-1 text-xs text-muted-foreground">
({noteService.activeNotes.length})
</span>
{/if}
</Tabs.Trigger>
<Tabs.Trigger value="pinned">
Pinned
{#if !searchQuery}
<span class="ml-1 text-xs text-muted-foreground">
({noteService.pinnedNotes.length})
</span>
{/if}
</Tabs.Trigger>
<Tabs.Trigger value="archived">
Archived
{#if !searchQuery}
<span class="ml-1 text-xs text-muted-foreground">
({noteService.archivedNotes.length})
</span>
{/if}
</Tabs.Trigger>
<Tabs.Trigger value="trash">
Trash
{#if !searchQuery}
<span class="ml-1 text-xs text-muted-foreground">
({noteService.trashedNotes.length})
</span>
{/if}
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</div>
<!-- Notes Grid -->
<NotesGrid
notes={filteredNotes()}
{view}
loading={noteService.loading}
emptyMessage={searchQuery
? 'No notes match your search'
: currentFilter === 'pinned'
? 'No pinned notes'
: currentFilter === 'archived'
? 'No archived notes'
: currentFilter === 'trash'
? 'Trash is empty'
: 'No notes yet'}
onNoteClick={(note) => openNote(note.id)}
/>
</div>
@@ -0,0 +1,135 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import {
EllipsisVertical,
Pin,
Archive,
ArchiveRestore,
Trash2,
RotateCcw,
XCircle
} from '@lucide/svelte';
import { getNoteService } from '$lib/context/notes.svelte';
import type { Note } from '$lib/triplit/schema';
const noteService = getNoteService();
let { note }: { note: Note } = $props();
let deleteDialog = $state(false);
let permanentDeleteDialog = $state(false);
async function handlePin() {
await noteService.pinNote(note.id, !note.pinned);
}
async function handleArchive() {
await noteService.archiveNote(note.id, !note.archived);
}
async function handleDelete() {
await noteService.deleteNote(note.id);
deleteDialog = false;
}
async function handleRestore() {
await noteService.restoreNote(note.id);
}
async function handlePermanentDelete() {
await noteService.permanentDelete(note.id);
permanentDeleteDialog = false;
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon-sm">
<EllipsisVertical class="h-4 w-4" />
<span class="sr-only">Note actions</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-48">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Actions</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#if !note.deleted}
<!-- Pin/Unpin -->
<DropdownMenu.Item onclick={handlePin}>
<Pin class="h-4 w-4" />
{note.pinned ? 'Unpin' : 'Pin note'}
</DropdownMenu.Item>
<!-- Archive/Unarchive -->
{#if !note.archived}
<DropdownMenu.Item onclick={handleArchive}>
<Archive class="h-4 w-4" />
Archive
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item onclick={handleArchive}>
<ArchiveRestore class="h-4 w-4" />
Unarchive
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<!-- Delete -->
<DropdownMenu.Item onclick={() => (deleteDialog = true)}>
<Trash2 class="h-4 w-4 text-destructive" />
<span>Delete</span>
</DropdownMenu.Item>
{:else}
<!-- Restore from trash -->
<DropdownMenu.Item onclick={handleRestore}>
<RotateCcw class="h-4 w-4" />
Restore
</DropdownMenu.Item>
<DropdownMenu.Separator />
<!-- Permanent delete -->
<DropdownMenu.Item onclick={() => (permanentDeleteDialog = true)}>
<XCircle class="h-4 w-4 text-destructive" />
<span class="text-destructive">Delete forever</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Move to trash?</AlertDialog.Title>
<AlertDialog.Description>
This note will be moved to trash. You can restore it within 30 days.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<Button onclick={handleDelete} variant="destructive">Move to trash</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Permanent Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={permanentDeleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete permanently?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete the note.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<Button onclick={handlePermanentDelete} variant="destructive">Delete forever</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Pin } from '@lucide/svelte';
import type { Note } from '$lib/triplit/schema';
import NoteActionMenu from './NoteActionMenu.svelte';
import { Carta, Markdown } from 'carta-md';
import DOMPurify from 'isomorphic-dompurify';
let { note, view = 'grid', onclick }: { note: Note; view?: 'grid' | 'list'; onclick?: () => void } = $props();
const carta = new Carta({ sanitizer: DOMPurify.sanitize });
// Get first line of content as title if title is empty
const displayTitle = $derived(note.title || note.content.split('\n')[0] || 'Untitled');
// Truncate content for preview
const previewContent = $derived(() => {
const lines = view === 'grid' ? 3 : 2;
const contentLines = note.content.split('\n').slice(0, lines);
const preview = contentLines.join('\n');
return preview.length > 150 ? preview.substring(0, 150) + '...' : preview;
});
// Format relative time
function formatRelativeTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (years > 0) return `${years}y ago`;
if (months > 0) return `${months}mo ago`;
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}
const tags = $derived(Array.from(note.tags));
</script>
<Card.Root
class="cursor-pointer transition-all hover:shadow-md gap-2 {view === 'list' ? 'flex flex-row' : ''}"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onclick?.();
}
}}
>
<div class="flex items-start justify-between gap-2 px-6">
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if note.pinned}
<Pin class="h-4 w-4 text-muted-foreground shrink-0" />
{/if}
<h3 class="font-semibold text-base line-clamp-1">{displayTitle}</h3>
</div>
<div onclick={(e) => e.stopPropagation()}>
<NoteActionMenu {note} />
</div>
</div>
<Card.Content class="flex-1">
<!-- Content Preview -->
{#if note.content}
<div class="note-preview text-sm text-muted-foreground {view === 'grid' ? 'line-clamp-3' : 'line-clamp-2'} overflow-hidden">
<Markdown {carta} value={previewContent()} />
</div>
{/if}
<!-- Tags -->
{#if tags.length > 0}
<div class="flex flex-wrap gap-1 mt-3">
{#each tags.slice(0, 3) as tag (tag)}
<Badge variant="outline" class="text-xs">
{tag}
</Badge>
{/each}
{#if tags.length > 3}
<Badge variant="outline" class="text-xs">
+{tags.length - 3}
</Badge>
{/if}
</div>
{/if}
</Card.Content>
<Card.Footer>
<span class="text-xs text-muted-foreground">
{formatRelativeTime(note.updatedAt)}
</span>
</Card.Footer>
</Card.Root>
<style>
.note-preview :global(*) {
margin: 0;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Carta, MarkdownEditor } from 'carta-md';
import DOMPurify from 'isomorphic-dompurify';
import { Input } from '$lib/components/ui/input/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { getNoteService } from '$lib/context/notes.svelte';
// Import Carta styles
import 'carta-md/default.css';
// import 'carta-md/light.css';
const noteService = getNoteService();
let { noteId }: { noteId: string } = $props();
let note = $derived(noteService.getNoteById(noteId));
let title = $state('');
let content = $state('');
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let lastSaved = $state<Date | null>(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
let initializedNoteId = $state<string | null>(null);
let lastSavedTitle = $state('');
let lastSavedContent = $state('');
// Initialize Carta with DOMPurify sanitization
// const carta = new Carta({
// sanitizer: DOMPurify.sanitize
// });
const carta = new Carta({
sanitizer: false,
extensions: [
// attachment({
// async upload() {
// return 'some-url-from-server.xyz';
// }
// }),
// emoji(),
// slash(),
// code()
]
});
// Initialize title and content only when noteId changes
$effect(() => {
if (note && noteId !== initializedNoteId) {
title = note.title;
content = note.content;
lastSavedTitle = note.title;
lastSavedContent = note.content;
initializedNoteId = noteId;
}
});
// Auto-save functionality (500ms debounce after last change)
async function saveNote() {
if (!note) return;
hasUnsavedChanges = false;
isSaving = true;
try {
await noteService.updateNote(note.id, {
title: title.trim(),
content: content.trim()
});
lastSavedTitle = title.trim();
lastSavedContent = content.trim();
lastSaved = new Date();
} catch (err) {
console.error('Failed to save note:', err);
hasUnsavedChanges = true; // Restore unsaved state on error
} finally {
isSaving = false;
}
}
function debounceSave() {
hasUnsavedChanges = true;
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(() => {
saveNote();
}, 500);
}
// Save on title change (debounced - waits 500ms after last keystroke)
$effect(() => {
if (initializedNoteId && title !== lastSavedTitle) {
debounceSave();
}
});
// Save on content change (debounced - waits 500ms after last keystroke)
$effect(() => {
if (initializedNoteId && content !== lastSavedContent) {
debounceSave();
}
});
// Cleanup timeout on unmount
onMount(() => {
return () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
};
});
// Format last saved time
function formatSaveTime(date: Date | null): string {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 5) return 'Saved just now';
if (seconds < 60) return `Saved ${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `Saved ${minutes}m ago`;
return `Saved at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
</script>
{#if note}
<div class="h-full flex flex-col">
<!-- Title Input -->
<div class="p-4 pb-0">
<Input
bind:value={title}
placeholder="Note title..."
class="text-2xl font-semibold border-none shadow-none px-0 focus-visible:ring-0"
/>
</div>
<Separator class="my-4" />
<!-- Carta Editor -->
<div class="flex-1 px-4 pb-4 overflow-hidden">
<MarkdownEditor {carta} bind:value={content} mode="tabs" />
</div>
<!-- Save Status -->
<div class="px-4 py-2 border-t bg-muted/30">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{#if isSaving}
Saving...
{:else if hasUnsavedChanges}
Unsaved changes
{:else if lastSaved}
{formatSaveTime(lastSaved)}
{:else}
No changes
{/if}
</span>
<span>
{content.length} characters
</span>
</div>
</div>
</div>
{:else}
<div class="h-full flex items-center justify-center">
<p class="text-muted-foreground">Note not found</p>
</div>
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Search, X } from '@lucide/svelte';
let {
value = $bindable(''),
placeholder = 'Search notes...'
}: {
value?: string;
placeholder?: string;
} = $props();
function clear() {
value = '';
}
</script>
<div class="relative w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
bind:value
{placeholder}
class="pl-9 pr-9"
type="search"
/>
{#if value}
<Button
variant="ghost"
size="icon-sm"
class="absolute right-1 top-1/2 -translate-y-1/2"
onclick={clear}
>
<X class="h-4 w-4" />
<span class="sr-only">Clear search</span>
</Button>
{/if}
</div>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Note } from '$lib/triplit/schema';
import NoteCard from './NoteCard.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
let {
notes,
view = 'grid',
loading = false,
emptyMessage = 'No notes found',
onNoteClick
}: {
notes: Note[];
view?: 'grid' | 'list';
loading?: boolean;
emptyMessage?: string;
onNoteClick?: (note: Note) => void;
} = $props();
</script>
{#if loading}
<!-- Loading Skeletons -->
<div class="grid {view === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1'} gap-4">
{#each Array(6) as _}
<div class="space-y-3">
<Skeleton class="h-32 w-full" />
</div>
{/each}
</div>
{:else if notes.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-muted mb-4">
<svg
class="h-10 w-10 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">{emptyMessage}</h3>
<p class="text-muted-foreground max-w-sm">
{#if emptyMessage === 'No notes found'}
Create your first note to get started.
{:else}
{emptyMessage}
{/if}
</p>
</div>
{:else}
<!-- Notes Grid/List -->
<div
class="grid {view === 'grid'
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
: 'grid-cols-1'} gap-4"
>
{#each notes as note (note.id)}
<NoteCard {note} {view} onclick={() => onNoteClick?.(note)} />
{/each}
</div>
{/if}
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { X } from '@lucide/svelte';
import { getNoteService } from '$lib/context/notes.svelte';
import type { Note } from '$lib/triplit/schema';
const noteService = getNoteService();
let { note }: { note: Note } = $props();
let tagInput = $state('');
let tags = $derived(Array.from(note.tags));
async function addTag() {
const tag = tagInput.trim();
if (!tag || tags.includes(tag)) {
tagInput = '';
return;
}
await noteService.addTag(note.id, tag);
tagInput = '';
}
async function removeTag(tag: string) {
await noteService.removeTag(note.id, tag);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}
</script>
<div class="space-y-2">
<!-- Tag Input -->
<Input
bind:value={tagInput}
onkeydown={handleKeydown}
placeholder="Add a tag..."
class="text-sm"
/>
<!-- Tag List -->
{#if tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tags as tag (tag)}
<Badge variant="secondary" class="gap-1">
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="ml-1 hover:text-destructive transition-colors"
aria-label="Remove tag"
>
<X class="h-3 w-3" />
</button>
</Badge>
{/each}
</div>
{/if}
</div>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getNoteService } from '$lib/context/notes.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { ArrowLeft, Pin, Archive, ArchiveRestore, MoreVertical } from '@lucide/svelte';
import NoteEditor from '../NoteEditor.svelte';
import NoteActionMenu from '../NoteActionMenu.svelte';
import TagInput from '../TagInput.svelte';
const noteService = getNoteService();
const noteId = $derived($page.params.id);
const note = $derived(noteService.getNoteById(noteId));
let showTagDialog = $state(false);
async function handlePin() {
if (!note) return;
await noteService.pinNote(note.id, !note.pinned);
}
async function handleArchive() {
if (!note) return;
await noteService.archiveNote(note.id, !note.archived);
goto('/notes');
}
function goBack() {
goto('/notes');
}
</script>
{#if note}
<div class="h-screen flex flex-col">
<!-- Top App Bar -->
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onclick={goBack}>
<ArrowLeft class="h-4 w-4" />
<span class="sr-only">Back to notes</span>
</Button>
<span class="text-sm text-muted-foreground">Back to notes</span>
</div>
<div class="flex items-center gap-2">
<!-- Pin Button -->
<Button
variant={note.pinned ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={handlePin}
title={note.pinned ? 'Unpin note' : 'Pin note'}
>
<Pin class="h-4 w-4 {note.pinned ? 'fill-current' : ''}" />
</Button>
<!-- Archive Button -->
<Button
variant="ghost"
size="icon-sm"
onclick={handleArchive}
title={note.archived ? 'Unarchive note' : 'Archive note'}
>
{#if note.archived}
<ArchiveRestore class="h-4 w-4" />
{:else}
<Archive class="h-4 w-4" />
{/if}
</Button>
<!-- Tags Dialog Trigger -->
<Button variant="ghost" size="sm" onclick={() => (showTagDialog = true)}>
Tags
</Button>
<Separator orientation="vertical" class="h-6" />
<!-- More Options -->
<NoteActionMenu {note} />
</div>
</div>
</div>
</div>
<!-- Editor -->
<div class="flex-1 overflow-hidden">
<NoteEditor {noteId} />
</div>
</div>
<!-- Tags Dialog -->
<Dialog.Root bind:open={showTagDialog}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Manage Tags</Dialog.Title>
<Dialog.Description>
Add or remove tags to organize your notes.
</Dialog.Description>
</Dialog.Header>
<div class="py-4">
<TagInput {note} />
</div>
<Dialog.Footer>
<Button onclick={() => (showTagDialog = false)}>Done</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{:else}
<div class="h-screen flex items-center justify-center">
<div class="text-center">
<h2 class="text-2xl font-semibold mb-2">Note not found</h2>
<p class="text-muted-foreground mb-4">The note you're looking for doesn't exist.</p>
<Button onclick={goBack}>
<ArrowLeft class="h-4 w-4 mr-2" />
Back to notes
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNoteService } from '$lib/context/notes.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { ArrowLeft, LayoutGrid, List } from '@lucide/svelte';
import NotesGrid from '../NotesGrid.svelte';
import NoteSearchBar from '../NoteSearchBar.svelte';
const noteService = getNoteService();
let searchQuery = $state('');
let view = $state<'grid' | 'list'>('grid');
const filteredNotes = $derived(() => {
if (searchQuery.trim()) {
return noteService.searchNotes(searchQuery, noteService.archivedNotes);
}
return noteService.archivedNotes;
});
function openNote(noteId: string) {
goto(`/notes/${noteId}`);
}
</script>
<div class="w-full container mx-auto p-6 space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon-sm" onclick={() => goto('/notes')}>
<ArrowLeft class="h-4 w-4" />
<span class="sr-only">Back to notes</span>
</Button>
<div>
<h1 class="text-3xl font-bold tracking-tight text-foreground">Archived Notes</h1>
<p class="text-muted-foreground">
{noteService.archivedNotes.length}
{noteService.archivedNotes.length === 1 ? 'note' : 'notes'} archived
</p>
</div>
</div>
<!-- View Toggle -->
<div class="flex items-center border rounded-md">
<Button
variant={view === 'grid' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'grid')}
aria-label="Grid view"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant={view === 'list' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'list')}
aria-label="List view"
>
<List class="h-4 w-4" />
</Button>
</div>
</div>
<Separator />
<!-- Search -->
<NoteSearchBar bind:value={searchQuery} placeholder="Search archived notes..." />
<!-- Notes Grid -->
<NotesGrid
notes={filteredNotes()}
{view}
loading={noteService.loading}
emptyMessage={searchQuery ? 'No archived notes match your search' : 'No archived notes'}
onNoteClick={(note) => openNote(note.id)}
/>
</div>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNoteService } from '$lib/context/notes.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { ArrowLeft, LayoutGrid, List, Trash2 } from '@lucide/svelte';
import NotesGrid from '../NotesGrid.svelte';
import NoteSearchBar from '../NoteSearchBar.svelte';
const noteService = getNoteService();
let searchQuery = $state('');
let view = $state<'grid' | 'list'>('grid');
let emptyTrashDialog = $state(false);
const filteredNotes = $derived(() => {
if (searchQuery.trim()) {
return noteService.searchNotes(searchQuery, noteService.trashedNotes);
}
return noteService.trashedNotes;
});
async function handleEmptyTrash() {
await noteService.emptyTrash();
emptyTrashDialog = false;
}
function openNote(noteId: string) {
goto(`/notes/${noteId}`);
}
</script>
<div class="w-full container mx-auto p-6 space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon-sm" onclick={() => goto('/notes')}>
<ArrowLeft class="h-4 w-4" />
<span class="sr-only">Back to notes</span>
</Button>
<div>
<h1 class="text-3xl font-bold tracking-tight text-foreground">Trash</h1>
<p class="text-muted-foreground">
{noteService.trashedNotes.length}
{noteService.trashedNotes.length === 1 ? 'note' : 'notes'} in trash • Auto-delete after
30 days
</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- View Toggle -->
<div class="flex items-center border rounded-md">
<Button
variant={view === 'grid' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'grid')}
aria-label="Grid view"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant={view === 'list' ? 'secondary' : 'ghost'}
size="icon-sm"
onclick={() => (view = 'list')}
aria-label="List view"
>
<List class="h-4 w-4" />
</Button>
</div>
<!-- Empty Trash Button -->
{#if noteService.trashedNotes.length > 0}
<Button variant="destructive" onclick={() => (emptyTrashDialog = true)} class="gap-2">
<Trash2 class="h-4 w-4" />
Empty Trash
</Button>
{/if}
</div>
</div>
<Separator />
<!-- Search -->
<NoteSearchBar bind:value={searchQuery} placeholder="Search trash..." />
<!-- Notes Grid -->
<NotesGrid
notes={filteredNotes()}
{view}
loading={noteService.loading}
emptyMessage={searchQuery ? 'No trashed notes match your search' : 'Trash is empty'}
onNoteClick={(note) => openNote(note.id)}
/>
</div>
<!-- Empty Trash Confirmation Dialog -->
<AlertDialog.Root bind:open={emptyTrashDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Empty trash?</AlertDialog.Title>
<AlertDialog.Description>
This will permanently delete all notes in the trash. This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<Button onclick={handleEmptyTrash} variant="destructive">Empty trash</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>