56b8e9956a
- 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>
100 lines
3.2 KiB
Svelte
100 lines
3.2 KiB
Svelte
<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 NoteActionMenu from './NoteActionMenu.svelte';
|
|
import { Carta, Markdown } from 'carta-md';
|
|
import DOMPurify from 'isomorphic-dompurify';
|
|
import { getNoteService } from '$lib/context/notes.svelte';
|
|
|
|
let { noteId, view = 'grid', onclick }: { noteId: string; view?: 'grid' | 'list'; onclick?: () => void } = $props();
|
|
|
|
const noteService = getNoteService();
|
|
const note = $derived(noteService.getNoteById(noteId));
|
|
|
|
const carta = new Carta({
|
|
sanitizer: DOMPurify.sanitize,
|
|
theme: { light: 'github-light', dark: 'github-dark' }
|
|
});
|
|
|
|
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);
|
|
if (days > 0) return `${days}d ago`;
|
|
if (hours > 0) return `${hours}h ago`;
|
|
if (minutes > 0) return `${minutes}m ago`;
|
|
return 'Just now';
|
|
}
|
|
</script>
|
|
|
|
{#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>
|
|
</div>
|
|
|
|
<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(*) {
|
|
margin: 0;
|
|
}
|
|
</style>
|