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:
Vendored
+3
-1
@@ -11,7 +11,9 @@ declare global {
|
||||
|
||||
// interface Error {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
selectedNoteId?: string;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{#if note}
|
||||
<Card.Root
|
||||
class="cursor-pointer transition-all hover:shadow-md gap-2 {view === 'list' ? 'flex flex-row' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
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">
|
||||
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}
|
||||
<Pin class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<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}
|
||||
<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()} />
|
||||
{#key note.content}
|
||||
<Markdown {carta} value={getPreview(note.content)} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if tags.length > 0}
|
||||
{#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>
|
||||
<Badge variant="outline" class="text-xs">{tag}</Badge>
|
||||
{/each}
|
||||
{#if tags.length > 3}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
<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 class="justify-end">
|
||||
<span class="text-xs text-muted-foreground">{getRelativeTime(note.updatedAt)}</span>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.note-preview :global(*) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
{/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"
|
||||
>
|
||||
{@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 {note} {view} onclick={() => onNoteClick?.(note)} />
|
||||
<NoteCard noteId={note.id} {view} onclick={() => onNoteClick?.(note.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<NoteEditor {noteId} {noteService} />
|
||||
<NoteEditor {noteId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user