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
+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(*) {