refactor: simplify notes components to use service directly

- Remove prop drilling, components get notes from NoteService context
- NoteCard gets note by ID from service, uses {#key} for Markdown reactivity
- NotesGrid handles filtering internally via service methods
- NoteEditor gets service from context instead of prop
- Add PageState type for shallow routing selectedNoteId
- Add +layout.server.ts to pass user data to client

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:54:51 -05:00
parent 9363bbcc76
commit 56b8e9956a
7 changed files with 142 additions and 193 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ declare global {
// interface Error {}
// interface PageData {}
// interface PageState {}
interface PageState {
selectedNoteId?: string;
}
// interface Platform {}
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
session: locals.session
};
};
+10 -39
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { User } from 'better-auth';
import { pushState } from '$app/navigation';
import { page } from '$app/state';
import { setNoteService } from '$lib/context/notes.svelte';
@@ -11,37 +12,16 @@
import NoteSearchBar from './NoteSearchBar.svelte';
import NoteEditor from './NoteEditor.svelte';
// Get note service from layout context
const noteService = setNoteService('default-user');
let { data }: { data: { user: User } } = $props();
// Create note service with authenticated user
// svelte-ignore state_referenced_locally
const noteService = setNoteService(data.user.id);
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.by(() => {
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: '',
@@ -149,19 +129,10 @@
<!-- Notes Grid -->
<NotesGrid
notes={filteredNotes}
filter={currentFilter}
search={searchQuery}
{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)}
onNoteClick={openNote}
/>
</div>
@@ -169,7 +140,7 @@
<Dialog.Root open={!!selectedNoteId} onOpenChange={(open) => !open && closeNote()}>
<Dialog.Content class="w-[90vw]! max-w-6xl! h-[90vh] flex flex-col overflow-hidden">
{#if selectedNoteId}
<NoteEditor noteId={selectedNoteId} {noteService} />
<NoteEditor noteId={selectedNoteId} />
{/if}
</Dialog.Content>
</Dialog.Root>
+69 -77
View File
@@ -1,104 +1,96 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/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';
import { getNoteService } from '$lib/context/notes.svelte';
let { note, view = 'grid', onclick }: { note: Note; view?: 'grid' | 'list'; onclick?: () => void } = $props();
let { noteId, view = 'grid', onclick }: { noteId: string; view?: 'grid' | 'list'; onclick?: () => void } = $props();
const carta = new Carta({ sanitizer: DOMPurify.sanitize });
const noteService = getNoteService();
const note = $derived(noteService.getNoteById(noteId));
// 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;
const carta = new Carta({
sanitizer: DOMPurify.sanitize,
theme: { light: 'github-light', dark: 'github-dark' }
});
// 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);
function handleUnpin(e: Event) {
e.stopPropagation();
if (note) noteService.updateNote(note.id, { pinned: false });
}
function getPreview(content: string) {
const lines = view === 'grid' ? 3 : 2;
const preview = content.split('\n').slice(0, lines).join('\n');
return preview.length > 150 ? preview.substring(0, 150) + '...' : preview;
}
function getRelativeTime(date: Date) {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
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 note}
<Card.Root
class="cursor-pointer transition-all hover:shadow-md gap-2 {view === 'list' ? 'flex flex-row' : ''}"
role="button"
tabindex={0}
{onclick}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onclick?.())}
>
<div class="flex items-center justify-between gap-2 pl-6 pr-2">
<h3 class="font-semibold text-base line-clamp-1 flex-1 min-w-0">
{note.title || note.content.split('\n')[0] || 'Untitled'}
</h3>
<div class="flex items-center gap-1 shrink-0">
{#if note.pinned}
<Button variant="ghost" size="icon-sm" onclick={handleUnpin} title="Unpin note" class="group">
<Pin class="h-4 w-4 text-primary transition-all group-hover:rotate-45 group-hover:text-muted-foreground" />
<span class="sr-only">Unpin note</span>
</Button>
{/if}
<NoteActionMenu {note} />
</div>
{/if}
</Card.Content>
</div>
<Card.Footer>
<span class="text-xs text-muted-foreground">
{formatRelativeTime(note.updatedAt)}
</span>
</Card.Footer>
</Card.Root>
<Card.Content class="flex-1">
{#if note.content}
<div class="note-preview text-sm text-muted-foreground {view === 'grid' ? 'line-clamp-3' : 'line-clamp-2'} overflow-hidden">
{#key note.content}
<Markdown {carta} value={getPreview(note.content)} />
{/key}
</div>
{/if}
{#if note.tags.size > 0}
{@const tags = Array.from(note.tags)}
<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 class="justify-end">
<span class="text-xs text-muted-foreground">{getRelativeTime(note.updatedAt)}</span>
</Card.Footer>
</Card.Root>
{/if}
<style>
.note-preview :global(*) {
+20 -25
View File
@@ -8,13 +8,11 @@
// 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));
const noteService = getNoteService();
const note = $derived(noteService.getNoteById(noteId));
let title = $state('');
let content = $state('');
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -25,23 +23,13 @@
let lastSavedTitle = $state('');
let lastSavedContent = $state('');
// Initialize Carta with DOMPurify sanitization
// const carta = new Carta({
// sanitizer: DOMPurify.sanitize
// });
// Initialize Carta with DOMPurify sanitization and dual theme for light/dark mode
const carta = new Carta({
sanitizer: false,
extensions: [
// attachment({
// async upload() {
// return 'some-url-from-server.xyz';
// }
// }),
// emoji(),
// slash(),
// code()
]
sanitizer: DOMPurify.sanitize,
theme: {
light: 'github-light',
dark: 'github-dark'
}
});
// Initialize title and content only when noteId changes
@@ -132,24 +120,23 @@
{#if note}
<div class="h-full flex flex-col">
<!-- Title Input -->
<div class="p-4 pb-0">
<div class="px-4 pt-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"
class="text-xl! h-auto! font-semibold border-none shadow-none px-3 focus-visible:ring-0"
/>
</div>
<Separator class="my-4" />
<!-- Carta Editor -->
<div class="flex-1 px-4 pb-4 overflow-hidden">
<div class="flex-1 min-h-0 px-4 pb-4">
<MarkdownEditor {carta} bind:value={content} mode="tabs" />
</div>
<!-- Save Status -->
<div class="px-4 py-2 border-t bg-muted/30">
<div class="px-4">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{#if isSaving}
@@ -174,3 +161,11 @@
</div>
{/if}
<style>
:global(.carta-editor) {
height: 100% !important;
max-height: 100% !important;
overflow: hidden;
}
</style>
+31 -50
View File
@@ -1,68 +1,49 @@
<script lang="ts">
import type { Note } from '$lib/triplit/schema';
import { getNoteService } from '$lib/context/notes.svelte';
import NoteCard from './NoteCard.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
let {
notes,
filter = 'all',
search = '',
view = 'grid',
loading = false,
emptyMessage = 'No notes found',
onNoteClick
}: {
notes: Note[];
filter?: 'all' | 'pinned' | 'archived' | 'trash';
search?: string;
view?: 'grid' | 'list';
loading?: boolean;
emptyMessage?: string;
onNoteClick?: (note: Note) => void;
onNoteClick?: (noteId: string) => void;
} = $props();
const noteService = getNoteService();
</script>
{#if loading}
<!-- Loading Skeletons -->
{#if noteService.loading}
<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>
<Skeleton class="h-32 w-full" />
{/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>
{@const baseNotes = filter === 'pinned' ? noteService.pinnedNotes : filter === 'archived' ? noteService.archivedNotes : filter === 'trash' ? noteService.trashedNotes : noteService.activeNotes}
{@const notes = search.trim() ? noteService.searchNotes(search, baseNotes) : baseNotes}
{#if notes.length === 0}
<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">
{search ? 'No notes match your search' : filter === 'pinned' ? 'No pinned notes' : filter === 'archived' ? 'No archived notes' : filter === 'trash' ? 'Trash is empty' : 'No notes yet'}
</h3>
</div>
{:else}
<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 noteId={note.id} {view} onclick={() => onNoteClick?.(note.id)} />
{/each}
</div>
{/if}
{/if}
+1 -1
View File
@@ -87,7 +87,7 @@
<!-- Editor -->
<div class="flex-1 overflow-hidden">
<NoteEditor {noteId} {noteService} />
<NoteEditor {noteId} />
</div>
</div>