feat: Crumbs by Bretzel — self-hostable notes app MVP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-05 21:16:45 +01:00
committed by Frederic Cabassut
commit 1becc17724
89 changed files with 11359 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#202124" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Crumbs</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
}
// Check if setup is complete
const setupDone = await isSetupComplete();
if (!setupDone) {
if (pathname !== '/setup') {
throw redirect(302, '/setup');
}
return resolve(event);
}
// Validate session
const sessionToken = event.cookies.get('session');
if (!sessionToken || !(await validateSession(sessionToken))) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
throw redirect(302, '/login');
}
return resolve(event);
};
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
interface ChecklistItem {
text: string;
checked: boolean;
}
interface Props {
content: string;
onChange: (content: string) => void;
}
let { content, onChange }: Props = $props();
let items = $state<ChecklistItem[]>(parseChecklist(content));
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
return { text: line, checked: false };
});
}
function serializeChecklist(list: ChecklistItem[]): string {
return list
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
}
function emitChange() {
onChange(serializeChecklist(items));
}
function toggleItem(index: number) {
items[index].checked = !items[index].checked;
emitChange();
}
function updateText(index: number, text: string) {
items[index].text = text;
emitChange();
}
function addItem(afterIndex: number) {
items.splice(afterIndex + 1, 0, { text: '', checked: false });
items = [...items];
emitChange();
// Focus new input on next tick
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
}, 0);
}
function removeItem(index: number) {
if (items.length <= 1) return;
items.splice(index, 1);
items = [...items];
emitChange();
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(index);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
}
}
</script>
<div class="space-y-1" data-testid="checklist">
{#each items as item, index}
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(index)}
class="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, index)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-gray-400 line-through' : 'text-gray-800 dark:text-gray-200'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(index)}
class="opacity-0 hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
data-testid="checklist-add"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add item
</button>
</div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import type { NoteColor } from '$lib/types/index.js';
interface Props {
selected: NoteColor;
onSelect: (color: NoteColor) => void;
}
let { selected, onSelect }: Props = $props();
</script>
<div class="flex flex-wrap gap-1" data-testid="color-picker">
{#each COLOR_OPTIONS as option}
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800 dark:border-white' : 'border-transparent'}"
style="background-color: {$effectiveTheme === 'dark' ? colors.dark : colors.light}"
title={option.label}
data-testid="color-{option.value}"
></button>
{/each}
</div>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { Attachment } from '$lib/types/index.js';
interface Props {
noteId: string;
attachments: Attachment[];
onUpload: (attachment: Attachment) => void;
onRemove: (attachmentId: string) => void;
}
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let uploading = $state(false);
let dragOver = $state(false);
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: formData
});
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
}
} catch (err) {
console.error('Upload failed:', err);
}
}
uploading = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
handleFiles(e.dataTransfer?.files ?? null);
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragOver = true;
}
</script>
<div data-testid="image-upload">
<!-- Existing attachments -->
{#if attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachments as attachment}
<div class="group relative">
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded object-cover"
data-testid="attachment-thumbnail"
/>
<button
onclick={() => onRemove(attachment.id)}
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Upload area -->
<div
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20' : 'border-gray-300 dark:border-gray-600'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-gray-500">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import SearchBar from '../SearchBar.svelte';
import ThemeToggle from '../ThemeToggle.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
interface Props {
onMenuToggle: () => void;
}
let { onMenuToggle }: Props = $props();
</script>
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<button
onclick={onMenuToggle}
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle sidebar"
>
<svg class="h-6 w-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex items-center gap-2">
<span class="text-2xl">🥨</span>
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Crumbs</h1>
</div>
<div class="mx-4 flex-1">
<SearchBar />
</div>
<div class="flex items-center gap-2">
<SyncIndicator />
<ThemeToggle />
</div>
</header>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
import type { NoteFilter } from '$lib/types/index.js';
interface Props {
open: boolean;
}
let { open }: Props = $props();
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
}
</script>
<aside
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 dark:border-gray-700 dark:bg-gray-900 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Notes
</button>
</li>
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archive
</button>
</li>
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Trash
</button>
</li>
</ul>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tags</h3>
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
#{tag}
</button>
</li>
{/each}
</ul>
</div>
{/if}
</nav>
</aside>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { writable } from 'svelte/store';
interface ToastMessage {
id: number;
text: string;
type: 'info' | 'success' | 'error';
action?: { label: string; handler: () => void };
}
let counter = 0;
export const toasts = writable<ToastMessage[]>([]);
export function showToast(
text: string,
type: 'info' | 'success' | 'error' = 'info',
action?: { label: string; handler: () => void },
duration = 4000
) {
const id = ++counter;
toasts.update((t) => [...t, { id, text, type, action }]);
if (!action) {
setTimeout(() => dismiss(id), duration);
}
}
export function dismiss(id: number) {
toasts.update((t) => t.filter((toast) => toast.id !== id));
}
</script>
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{#each $toasts as toast (toast.id)}
<div
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'}"
>
<span class="text-sm">{toast.text}</span>
{#if toast.action}
<button
onclick={() => { toast.action?.handler(); dismiss(toast.id); }}
class="text-sm font-semibold underline"
>
{toast.action.label}
</button>
{/if}
<button onclick={() => dismiss(toast.id)} class="ml-2 opacity-70 hover:opacity-100">&times;</button>
</div>
{/each}
</div>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import { stripMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
interface Props {
note: Note;
onEdit: (note: Note) => void;
}
let { note, onEdit }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
const bg = $effectiveTheme === 'dark' ? colors.dark : colors.light;
cardStyle = `background-color: ${bg}`;
});
let cardStyle = $state('');
const preview = $derived(stripMarkdown(note.content).slice(0, 200));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
fn();
};
}
</script>
<article
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
role="button"
tabindex="0"
data-testid="note-card"
data-note-id={note.id}
>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">{note.title}</h3>
{/if}
{#if preview}
<p class="line-clamp-6 text-sm text-gray-700 dark:text-gray-300">{preview}</p>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700/60 dark:text-gray-400">
#{tag}
</span>
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Restore"
data-testid="restore-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Delete forever"
data-testid="delete-forever-btn"
>
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{:else}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title={note.pinned ? 'Unpin' : 'Pin'}
data-testid="pin-btn"
>
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Unarchive"
data-testid="unarchive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Archive"
data-testid="archive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Delete"
data-testid="trash-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
<!-- Pin indicator -->
{#if note.pinned}
<div class="absolute right-2 top-2">
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
{/if}
</article>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import type { Note, NoteColor } from '$lib/types/index.js';
interface Props {
note: Note | null;
isNew?: boolean;
onClose: () => void;
}
let { note, isNew = false, onClose }: Props = $props();
let title = $state(note?.title || '');
let content = $state(note?.content || '');
let color = $state<NoteColor>(note?.color || 'default');
let checklistMode = $state(note?.checklistMode || false);
let showPreview = $state(false);
let showColorPicker = $state(false);
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${$effectiveTheme === 'dark' ? colors.dark : colors.light}`;
});
const renderedContent = $derived(renderMarkdown(content));
async function save() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (isNew) {
await createNote({ title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { title, content, color, checklistMode });
}
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
save();
}
}
function handleColorSelect(c: NoteColor) {
color = c;
showColorPicker = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
onclick={save}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl dark:border-gray-700"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500 dark:text-gray-100"
data-testid="note-title-input"
/>
<!-- Content -->
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if showPreview}
<div
class="prose prose-sm dark:prose-invert min-h-[100px] max-w-none px-4 py-2"
data-testid="note-preview"
>
{@html renderedContent}
</div>
{:else}
<textarea
placeholder="Take a note..."
bind:value={content}
class="min-h-[100px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
rows="6"
data-testid="note-content-input"
></textarea>
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2 dark:border-gray-700/50">
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
title="Background color"
data-testid="color-picker-toggle"
>
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{#if showColorPicker}
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
</div>
<!-- Preview toggle -->
<button
onclick={() => (showPreview = !showPreview)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
title={showPreview ? 'Edit' : 'Preview'}
data-testid="preview-toggle"
>
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if showPreview}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
{/if}
</svg>
</button>
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10 {checklistMode ? 'text-amber-600' : ''}"
title="Checklist mode"
data-testid="checklist-toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
</div>
<button
onclick={save}
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10 dark:text-gray-300 dark:hover:bg-white/10"
data-testid="close-editor-btn"
>
Close
</button>
</div>
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import type { Note } from '$lib/types/index.js';
interface Props {
notes: Note[];
label?: string;
onEdit: (note: Note) => void;
}
let { notes, label = '', onEdit }: Props = $props();
</script>
{#if notes.length > 0}
{#if label}
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</p>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each notes as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{/if}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
async function handleSearch() {
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
return;
}
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
}
}
function clearSearch() {
query = '';
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
}
</script>
<div class="relative max-w-2xl">
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2 dark:bg-gray-800">
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search notes..."
bind:value={query}
oninput={handleSearch}
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
data-testid="search-input"
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" aria-label="Clear search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
// Placeholder for Phase 6 - will be connected to sync store
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
</script>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
{#if status === 'synced'}
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{:else if status === 'syncing'}
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{:else if status === 'offline'}
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
{:else}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{/if}
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { allTags, selectedTag } from '$lib/stores/notes.js';
function toggleTag(tag: string) {
selectedTag.update((current) => (current === tag ? null : tag));
}
</script>
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<button
onclick={() => toggleTag(tag)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
data-testid="tag-chip"
>
#{tag}
</button>
{/each}
</div>
{/if}
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { effectiveTheme, toggleTheme } from '$lib/stores/theme.js';
</script>
<button
onclick={toggleTheme}
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle dark mode"
data-testid="theme-toggle"
>
{#if $effectiveTheme === 'dark'}
<svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="h-5 w-5 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq } from 'drizzle-orm';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
// Ensure attachments directory exists
if (!existsSync(ATTACHMENTS_DIR)) {
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
}
export async function saveAttachment(
noteId: string,
file: File
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
const filename = `${id}.${ext}`;
const filePath = join(ATTACHMENTS_DIR, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const [attachment] = await db
.insert(attachments)
.values({
id,
noteId,
filename: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
createdAt: new Date()
})
.returning();
return attachment;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
if (attachment) {
try {
await unlink(attachment.path);
} catch {
// File may already be deleted
}
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
describe('Auth - Password Hashing', () => {
it('should hash and verify a password correctly', async () => {
const password = 'mysecurepassword';
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = 'mysecurepassword';
const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
expect(hash1).not.toBe(hash2);
expect(await argon2.verify(hash1, password)).toBe(true);
expect(await argon2.verify(hash2, password)).toBe(true);
});
});
describe('Auth - User Setup', () => {
it('should create a user with hashed password', async () => {
const password = 'testpassword123';
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
});
it('should only allow one user', async () => {
await db.insert(users).values({
passwordHash: 'hash1',
createdAt: new Date()
});
const allUsers = await db.select().from(users);
expect(allUsers).toHaveLength(1);
});
});
describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000)
});
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeDefined();
expect(session!.userId).toBe(user.id);
expect(session!.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() - 1000) // Expired
});
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeDefined();
expect(session!.expiresAt.getTime()).toBeLessThan(Date.now());
});
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000)
});
await db.delete(sessions).where(eq(sessions.id, token));
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export async function isSetupComplete(): Promise<boolean> {
const user = await db.select().from(users).get();
return !!user;
}
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
}
export async function verifyPassword(password: string): Promise<boolean> {
const user = await db.select().from(users).get();
if (!user) return false;
return argon2.verify(user.passwordHash, password);
}
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
return token;
}
export async function validateSession(token: string): Promise<boolean> {
if (!token) return false;
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return false;
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
}
return true;
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, token));
}
export async function cleanExpiredSessions(): Promise<void> {
const now = new Date();
// Delete all expired sessions by checking each one
const allSessions = await db.select().from(sessions);
for (const session of allSessions) {
if (session.expiresAt < now) {
await db.delete(sessions).where(eq(sessions.id, session.id));
}
}
}
+82
View File
@@ -0,0 +1,82 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js';
import { existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
const DATABASE_URL = process.env.DATABASE_URL || './data/crumbs.db';
// Ensure the data directory exists
const dir = dirname(DATABASE_URL);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Auto-create tables on first run
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
trashed_at INTEGER,
checklist_mode INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
`);
export const db = drizzle(sqlite, { schema });
export { sqlite };
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, noteTags, tags } from './schema.js';
import { eq, and } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
describe('Notes CRUD', () => {
it('should create a note with default values', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Test Note',
content: 'Hello world',
createdAt: now,
updatedAt: now
});
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result).toBeDefined();
expect(result!.title).toBe('Test Note');
expect(result!.content).toBe('Hello world');
expect(result!.color).toBe('default');
expect(result!.pinned).toBe(false);
expect(result!.archived).toBe(false);
expect(result!.trashed).toBe(false);
expect(result!.checklistMode).toBe(false);
expect(result!.version).toBe(1);
});
it('should create a note with custom color and pinned state', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Colored Note',
content: '',
color: 'coral',
pinned: true,
createdAt: now,
updatedAt: now
});
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.color).toBe('coral');
expect(result!.pinned).toBe(true);
});
it('should read all active notes (not archived, not trashed)', async () => {
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
{
id: 'n2',
title: 'Archived',
content: '',
archived: true,
createdAt: now,
updatedAt: now
},
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]);
const active = await db
.select()
.from(notes)
.where(and(eq(notes.archived, false), eq(notes.trashed, false)));
expect(active).toHaveLength(1);
expect(active[0].title).toBe('Active');
});
it('should update a note and increment version', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Original',
content: '',
createdAt: now,
updatedAt: now
});
const later = new Date(now.getTime() + 1000);
await db
.update(notes)
.set({ title: 'Updated', updatedAt: later, version: 2 })
.where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.title).toBe('Updated');
expect(result!.version).toBe(2);
});
it('should soft-delete a note (move to trash)', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Trash',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ trashed: true, trashedAt: now }).where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.trashed).toBe(true);
expect(result!.trashedAt).toBeTruthy();
});
it('should permanently delete a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Delete',
content: '',
createdAt: now,
updatedAt: now
});
await db.delete(notes).where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result).toBeUndefined();
});
it('should archive and unarchive a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Archive',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ archived: true }).where(eq(notes.id, id));
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.archived).toBe(true);
await db.update(notes).set({ archived: false }).where(eq(notes.id, id));
result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.archived).toBe(false);
});
it('should pin and unpin a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Pin Me',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ pinned: true }).where(eq(notes.id, id));
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.pinned).toBe(true);
await db.update(notes).set({ pinned: false }).where(eq(notes.id, id));
result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.pinned).toBe(false);
});
});
describe('Notes with Tags', () => {
it('should associate tags with a note', async () => {
const noteId = uuidv4();
const now = new Date();
await db.insert(notes).values({
id: noteId,
title: 'Tagged Note',
content: '',
createdAt: now,
updatedAt: now
});
// Create tags
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
// Associate
await db.insert(noteTags).values([
{ noteId, tagId: tag1.id },
{ noteId, tagId: tag2.id }
]);
// Query
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, noteId));
expect(tagRows).toHaveLength(2);
expect(tagRows.map((t) => t.name).sort()).toEqual(['important', 'work']);
});
it('should cascade delete tags when note is deleted', async () => {
const noteId = uuidv4();
const now = new Date();
await db.insert(notes).values({
id: noteId,
title: '',
content: '',
createdAt: now,
updatedAt: now
});
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
const remaining = await db
.select()
.from(noteTags)
.where(eq(noteTags.noteId, noteId));
expect(remaining).toHaveLength(0);
// Tag itself should still exist
const tagStillExists = await db.select().from(tags).where(eq(tags.id, tag.id)).get();
expect(tagStillExists).toBeDefined();
});
});
describe('Note ordering', () => {
it('should return pinned notes first', async () => {
const now = new Date();
await db.insert(notes).values([
{
id: 'unpinned',
title: 'Unpinned',
content: '',
pinned: false,
createdAt: now,
updatedAt: now
},
{
id: 'pinned',
title: 'Pinned',
content: '',
pinned: true,
createdAt: now,
updatedAt: now
}
]);
const result = await db
.select()
.from(notes)
.orderBy(({ pinned }) => [
// SQLite: true = 1, false = 0, DESC puts 1 first
eq(pinned, pinned) // just use raw desc
]);
// Manual approach: order by pinned desc
const { desc } = await import('drizzle-orm');
const ordered = await db
.select()
.from(notes)
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
expect(ordered[0].title).toBe('Pinned');
expect(ordered[1].title).toBe('Unpinned');
});
});
+71
View File
@@ -0,0 +1,71 @@
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
version: integer('version').default(1).notNull()
});
export const tags = sqliteTable(
'tags',
{
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
);
export const noteTags = sqliteTable('note_tags', {
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
tagId: integer('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull()
});
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
path: text('path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const syncLog = sqliteTable('sync_log', {
id: integer('id').primaryKey({ autoIncrement: true }),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
operation: text('operation').notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
clientId: text('client_id').notNull()
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, tags, noteTags } from './schema.js';
import { and, eq, like, or } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(async () => {
const testDb = createTestDb();
db = testDb.db;
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
]);
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
});
describe('Search', () => {
it('should search by title', async () => {
const pattern = '%shopping%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should search by content', async () => {
const pattern = '%tomato%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.content, pattern)));
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n3');
});
it('should search across title and content', async () => {
const pattern = '%milk%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should not include trashed notes', async () => {
const pattern = '%should not%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(0);
});
it('should return empty for no matches', async () => {
const pattern = '%nonexistent%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(0);
});
it('should be case-insensitive with LIKE', async () => {
const pattern = '%SHOPPING%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
// SQLite LIKE is case-insensitive for ASCII by default
expect(results).toHaveLength(1);
});
it('should search by tag name', async () => {
const tagResults = await db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, '%work%'));
expect(tagResults).toHaveLength(1);
expect(tagResults[0].noteId).toBe('n2');
});
});
+76
View File
@@ -0,0 +1,76 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js';
/**
* Create an in-memory SQLite database for testing.
* Each test gets a fresh database.
*/
export function createTestDb() {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Create tables
sqlite.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
trashed_at INTEGER,
checklist_mode INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
`);
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
+104
View File
@@ -0,0 +1,104 @@
import { writable, derived } from 'svelte/store';
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
export const selectedTag = writable<string | null>(null);
export const filteredNotes = derived(
[notes, selectedTag, currentFilter],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
result = result.filter((n) => n.archived && !n.trashed);
} else if ($filter === 'trashed') {
result = result.filter((n) => n.trashed);
}
if ($selectedTag) {
result = result.filter((n) => n.tags?.includes($selectedTag));
}
return result;
}
);
export const pinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => n.pinned)
);
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => !n.pinned)
);
export const allTags = derived(notes, ($notes) => {
const tagSet = new Set<string>();
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
return [...tagSet].sort();
});
export async function loadNotes(filter: NoteFilter = 'all') {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data = await res.json();
notes.set(data);
currentFilter.set(filter);
}
}
export async function createNote(note: NoteCreate): Promise<Note | null> {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note)
});
if (res.ok) {
const created = await res.json();
notes.update((list) => [created, ...list]);
return created;
}
return null;
}
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (res.ok) {
const updated = await res.json();
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
return updated;
}
return null;
}
export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
return true;
}
return false;
}
export async function trashNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: true });
}
export async function restoreNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: false });
}
export async function archiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: true });
}
export async function unarchiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: false });
}
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
+46
View File
@@ -0,0 +1,46 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type Theme = 'light' | 'dark' | 'system';
function getInitialTheme(): Theme {
if (!browser) return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
}
function getEffectiveTheme(theme: Theme): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (!browser) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export const themePreference = writable<Theme>(getInitialTheme());
export const effectiveTheme = writable<'light' | 'dark'>('light');
if (browser) {
themePreference.subscribe((theme) => {
localStorage.setItem('theme', theme);
const effective = getEffectiveTheme(theme);
effectiveTheme.set(effective);
document.documentElement.classList.toggle('dark', effective === 'dark');
});
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
themePreference.update((current) => {
if (current === 'system') {
const effective = getEffectiveTheme('system');
effectiveTheme.set(effective);
document.documentElement.classList.toggle('dark', effective === 'dark');
}
return current;
});
});
}
export function toggleTheme() {
themePreference.update((current) => {
const effective = getEffectiveTheme(current);
return effective === 'dark' ? 'light' : 'dark';
});
}
+128
View File
@@ -0,0 +1,128 @@
import { browser } from '$app/environment';
import type { Note } from '$lib/types/index.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
import { mergeNotes } from './crdt.js';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
let syncStatus: SyncStatus = 'synced';
let syncInterval: ReturnType<typeof setInterval> | null = null;
const statusListeners: Set<(status: SyncStatus) => void> = new Set();
export function onSyncStatusChange(listener: (status: SyncStatus) => void) {
statusListeners.add(listener);
return () => statusListeners.delete(listener);
}
function setStatus(status: SyncStatus) {
syncStatus = status;
statusListeners.forEach((l) => l(status));
}
export function getSyncStatus(): SyncStatus {
return syncStatus;
}
/**
* Push local changes to server.
*/
async function pushChanges(): Promise<boolean> {
const queue = await getSyncQueue();
if (queue.length === 0) return true;
try {
const res = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: queue })
});
if (res.ok) {
await clearSyncQueue();
return true;
}
return false;
} catch {
return false;
}
}
/**
* Pull remote changes and merge with local state.
*/
async function pullChanges(): Promise<Note[]> {
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
try {
const res = await fetch(`/api/sync?since=${lastSync}`);
if (!res.ok) return [];
const remoteNotes: Note[] = await res.json();
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
}
}
await setMeta('lastSyncTimestamp', String(Date.now()));
return remoteNotes;
} catch {
return [];
}
}
/**
* Full sync cycle: push then pull.
*/
export async function sync(): Promise<void> {
if (!browser || !navigator.onLine) {
setStatus('offline');
return;
}
setStatus('syncing');
try {
const pushed = await pushChanges();
if (!pushed) {
setStatus('error');
return;
}
await pullChanges();
setStatus('synced');
} catch {
setStatus('error');
}
}
/**
* Start background sync (every 30 seconds).
*/
export function startSync() {
if (!browser) return;
// Initial sync
sync();
// Periodic sync
syncInterval = setInterval(sync, 30_000);
// Sync on online/offline events
window.addEventListener('online', () => sync());
window.addEventListener('offline', () => setStatus('offline'));
}
export function stopSync() {
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { Note } from '$lib/types/index.js';
/**
* Last-Write-Wins (LWW) merge strategy per field.
* For a single-user app with multiple devices, this is sufficient.
* Each field is resolved by comparing updatedAt timestamps.
*/
export function mergeNotes(local: Note, remote: Note): Note {
// If remote is newer overall, take remote
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();
if (remoteTime > localTime) {
return { ...remote };
}
if (localTime > remoteTime) {
return { ...local };
}
// Same timestamp - prefer higher version number
if (remote.version > local.version) {
return { ...remote };
}
return { ...local };
}
/**
* Determine if a local note has changes compared to a remote note.
*/
export function hasChanges(local: Note, remote: Note): boolean {
return (
local.title !== remote.title ||
local.content !== remote.content ||
local.color !== remote.color ||
local.pinned !== remote.pinned ||
local.archived !== remote.archived ||
local.trashed !== remote.trashed ||
local.checklistMode !== remote.checklistMode ||
local.sortOrder !== remote.sortOrder
);
}
/**
* Generate a diff of changed fields between two notes.
*/
export function diffNotes(
oldNote: Note,
newNote: Note
): Partial<Note> {
const diff: Partial<Note> = {};
if (oldNote.title !== newNote.title) diff.title = newNote.title;
if (oldNote.content !== newNote.content) diff.content = newNote.content;
if (oldNote.color !== newNote.color) diff.color = newNote.color;
if (oldNote.pinned !== newNote.pinned) diff.pinned = newNote.pinned;
if (oldNote.archived !== newNote.archived) diff.archived = newNote.archived;
if (oldNote.trashed !== newNote.trashed) diff.trashed = newNote.trashed;
if (oldNote.checklistMode !== newNote.checklistMode) diff.checklistMode = newNote.checklistMode;
if (oldNote.sortOrder !== newNote.sortOrder) diff.sortOrder = newNote.sortOrder;
return diff;
}
+124
View File
@@ -0,0 +1,124 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { Note } from '$lib/types/index.js';
const DB_NAME = 'crumbs';
const DB_VERSION = 1;
interface CrumbsDB {
notes: {
key: string;
value: Note;
indexes: {
'by-updated': Date;
};
};
syncQueue: {
key: number;
value: SyncQueueItem;
indexes: {
'by-timestamp': number;
};
};
meta: {
key: string;
value: { key: string; value: string };
};
}
export interface SyncQueueItem {
id?: number;
noteId: string;
operation: 'create' | 'update' | 'delete';
data?: Partial<Note>;
timestamp: number;
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
if (!dbPromise) {
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Notes store
if (!db.objectStoreNames.contains('notes')) {
const noteStore = db.createObjectStore('notes', { keyPath: 'id' });
noteStore.createIndex('by-updated', 'updatedAt');
}
// Sync queue
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true
});
syncStore.createIndex('by-timestamp', 'timestamp');
}
// Metadata (e.g., last sync timestamp, client ID)
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
}
});
}
return dbPromise;
}
// Notes operations
export async function getAllNotes(): Promise<Note[]> {
const db = await getDb();
return db.getAll('notes');
}
export async function getNote(id: string): Promise<Note | undefined> {
const db = await getDb();
return db.get('notes', id);
}
export async function putNote(note: Note): Promise<void> {
const db = await getDb();
await db.put('notes', note);
}
export async function deleteNoteFromIdb(id: string): Promise<void> {
const db = await getDb();
await db.delete('notes', id);
}
export async function clearNotes(): Promise<void> {
const db = await getDb();
await db.clear('notes');
}
// Sync queue operations
export async function addToSyncQueue(item: Omit<SyncQueueItem, 'id'>): Promise<void> {
const db = await getDb();
await db.add('syncQueue', item as SyncQueueItem);
}
export async function getSyncQueue(): Promise<SyncQueueItem[]> {
const db = await getDb();
return db.getAll('syncQueue');
}
export async function clearSyncQueue(): Promise<void> {
const db = await getDb();
await db.clear('syncQueue');
}
export async function removeSyncQueueItem(id: number): Promise<void> {
const db = await getDb();
await db.delete('syncQueue', id);
}
// Meta operations
export async function getMeta(key: string): Promise<string | undefined> {
const db = await getDb();
const result = await db.get('meta', key);
return result?.value;
}
export async function setMeta(key: string, value: string): Promise<void> {
const db = await getDb();
await db.put('meta', { key, value });
}
+71
View File
@@ -0,0 +1,71 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt } from 'drizzle-orm';
import type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
for (const change of changes) {
switch (change.operation) {
case 'create':
case 'update': {
if (!change.data) continue;
const existing = await db.select().from(notes).where(eq(notes.id, change.noteId)).get();
if (existing) {
// LWW: only update if incoming timestamp is newer
if (change.timestamp > existing.updatedAt.getTime()) {
await db
.update(notes)
.set({
...change.data,
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(eq(notes.id, change.noteId));
}
} else if (change.operation === 'create' && change.data) {
await db.insert(notes).values({
id: change.noteId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
pinned: change.data.pinned || false,
archived: change.data.archived || false,
trashed: change.data.trashed || false,
checklistMode: change.data.checklistMode || false,
sortOrder: change.data.sortOrder || 0,
createdAt: new Date(change.timestamp),
updatedAt: new Date(change.timestamp),
version: 1
});
}
break;
}
case 'delete': {
await db.delete(notes).where(eq(notes.id, change.noteId));
break;
}
}
// Log sync operation
await db.insert(syncLog).values({
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
clientId: 'default'
});
}
}
/**
* Get all notes updated since a given timestamp.
*/
export async function getChangesSince(sinceTimestamp: number) {
const since = new Date(sinceTimestamp);
const allNotes = await db.select().from(notes);
// Filter notes updated after the timestamp
return allNotes.filter((n) => n.updatedAt > since);
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { mergeNotes, hasChanges, diffNotes } from './crdt.js';
import type { Note } from '../types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
return {
id: 'test-id',
title: 'Test',
content: 'Content',
color: 'default',
pinned: false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: false,
sortOrder: 0,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
version: 1,
...overrides
};
}
describe('mergeNotes (LWW)', () => {
it('should prefer remote when remote is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-01') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-02') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when local is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-02') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-01') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should prefer higher version when timestamps are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 2 });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when timestamps and versions are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 1 });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
});
describe('hasChanges', () => {
it('should detect title change', () => {
const a = makeNote({ title: 'A' });
const b = makeNote({ title: 'B' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect content change', () => {
const a = makeNote({ content: 'old' });
const b = makeNote({ content: 'new' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect color change', () => {
const a = makeNote({ color: 'default' });
const b = makeNote({ color: 'coral' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect pinned change', () => {
const a = makeNote({ pinned: false });
const b = makeNote({ pinned: true });
expect(hasChanges(a, b)).toBe(true);
});
it('should return false when notes are identical', () => {
const a = makeNote();
const b = makeNote();
expect(hasChanges(a, b)).toBe(false);
});
});
describe('diffNotes', () => {
it('should return only changed fields', () => {
const old = makeNote({ title: 'Old', content: 'Same' });
const updated = makeNote({ title: 'New', content: 'Same' });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.content).toBeUndefined();
});
it('should return empty object when no changes', () => {
const a = makeNote();
const b = makeNote();
const diff = diffNotes(a, b);
expect(Object.keys(diff)).toHaveLength(0);
});
it('should track multiple changed fields', () => {
const old = makeNote({ title: 'Old', color: 'default', pinned: false });
const updated = makeNote({ title: 'New', color: 'coral', pinned: true });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.color).toBe('coral');
expect(diff.pinned).toBe(true);
});
});
+77
View File
@@ -0,0 +1,77 @@
export interface Note {
id: string;
title: string;
content: string;
color: NoteColor;
pinned: boolean;
archived: boolean;
trashed: boolean;
trashedAt: Date | null;
checklistMode: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
version: number;
tags?: string[];
attachments?: Attachment[];
}
export interface NoteCreate {
title?: string;
content?: string;
color?: NoteColor;
pinned?: boolean;
checklistMode?: boolean;
}
export interface NoteUpdate {
title?: string;
content?: string;
color?: NoteColor;
pinned?: boolean;
archived?: boolean;
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
}
export interface Attachment {
id: string;
noteId: string;
filename: string;
mimeType: string;
size: number;
path: string;
createdAt: Date;
}
export interface Tag {
id: number;
name: string;
}
export interface SyncChange {
noteId: string;
operation: 'create' | 'update' | 'delete';
timestamp: Date;
clientId: string;
data?: Partial<Note>;
}
export type NoteColor =
| 'default'
| 'coral'
| 'peach'
| 'sand'
| 'mint'
| 'sage'
| 'fog'
| 'storm'
| 'dusk'
| 'blossom'
| 'clay'
| 'chalk';
export type NoteFilter = 'all' | 'archived' | 'trashed';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
+21
View File
@@ -0,0 +1,21 @@
import type { NoteColor } from '$lib/types/index.js';
export const NOTE_COLORS: Record<NoteColor, { light: string; dark: string; label: string }> = {
default: { light: '#ffffff', dark: '#202124', label: 'Default' },
coral: { light: '#faafa8', dark: '#77172e', label: 'Coral' },
peach: { light: '#f39f76', dark: '#692b17', label: 'Peach' },
sand: { light: '#fff8b8', dark: '#7c4a03', label: 'Sand' },
mint: { light: '#e2f6d3', dark: '#264d3b', label: 'Mint' },
sage: { light: '#b4ddd3', dark: '#0c625d', label: 'Sage' },
fog: { light: '#d4e4ed', dark: '#256377', label: 'Fog' },
storm: { light: '#aeccdc', dark: '#284255', label: 'Storm' },
dusk: { light: '#d3bfdb', dark: '#472e5b', label: 'Dusk' },
blossom: { light: '#f6e2dd', dark: '#6c394f', label: 'Blossom' },
clay: { light: '#e9e3d4', dark: '#4b443a', label: 'Clay' },
chalk: { light: '#efeff1', dark: '#232427', label: 'Chalk' }
};
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
value: value as NoteColor,
label
}));
+7
View File
@@ -0,0 +1,7 @@
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): T {
let timeoutId: ReturnType<typeof setTimeout>;
return ((...args: unknown[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), ms);
}) as T;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown, stripMarkdown } from './markdown.js';
describe('renderMarkdown', () => {
it('should render bold text', () => {
const html = renderMarkdown('**bold**');
expect(html).toContain('<strong>bold</strong>');
});
it('should render italic text', () => {
const html = renderMarkdown('*italic*');
expect(html).toContain('<em>italic</em>');
});
it('should render headings', () => {
const html = renderMarkdown('# Heading 1');
expect(html).toContain('<h1>Heading 1</h1>');
});
it('should render unordered lists', () => {
const html = renderMarkdown('- item 1\n- item 2');
expect(html).toContain('<li>item 1</li>');
expect(html).toContain('<li>item 2</li>');
});
it('should render ordered lists', () => {
const html = renderMarkdown('1. first\n2. second');
expect(html).toContain('<li>first</li>');
expect(html).toContain('<li>second</li>');
});
it('should render code blocks', () => {
const html = renderMarkdown('```\nconst x = 1;\n```');
expect(html).toContain('<code>');
expect(html).toContain('const x = 1;');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
});
it('should render links', () => {
const html = renderMarkdown('[Google](https://google.com)');
expect(html).toContain('href="https://google.com"');
expect(html).toContain('Google');
});
it('should render tables', () => {
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
const html = renderMarkdown(md);
expect(html).toContain('<table>');
expect(html).toContain('<td>1</td>');
});
it('should auto-linkify URLs', () => {
const html = renderMarkdown('Visit https://example.com');
expect(html).toContain('href="https://example.com"');
});
it('should convert line breaks', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
});
describe('stripMarkdown', () => {
it('should remove markdown formatting', () => {
expect(stripMarkdown('**bold** and *italic*')).toBe('bold and italic');
});
it('should remove code blocks', () => {
expect(stripMarkdown('```\ncode\n```')).toBe('');
});
it('should remove inline code backticks', () => {
expect(stripMarkdown('Use `command`')).toBe('Use');
});
it('should extract link text', () => {
expect(stripMarkdown('[Click here](https://example.com)')).toBe('Click here');
});
it('should return empty for empty input', () => {
expect(stripMarkdown('')).toBe('');
});
});
+44
View File
@@ -0,0 +1,44 @@
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true
});
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline') continue;
const content = tokens[i].content;
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
const checked = content.startsWith('[x] ');
const checkbox = checked
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
: '<input type="checkbox" disabled class="task-checkbox" /> ';
tokens[i].content = content.slice(4);
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
// Prepend checkbox
const token = new state.Token('html_inline', '', 0);
token.content = checkbox;
tokens[i].children!.unshift(token);
}
}
});
export function renderMarkdown(content: string): string {
return md.render(content);
}
export function stripMarkdown(content: string): string {
// Simple strip for preview - remove markdown syntax
return content
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1')
.replace(/[#*_~\[\]]/g, '')
.trim();
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { extractTags } from './tags.js';
describe('extractTags', () => {
it('should extract simple hashtags', () => {
expect(extractTags('Hello #world')).toEqual(['world']);
});
it('should extract multiple hashtags', () => {
expect(extractTags('#work #personal #todo')).toEqual(['work', 'personal', 'todo']);
});
it('should deduplicate tags', () => {
expect(extractTags('#work some text #work')).toEqual(['work']);
});
it('should lowercase tags', () => {
expect(extractTags('#Work #IMPORTANT')).toEqual(['work', 'important']);
});
it('should handle tags with hyphens', () => {
expect(extractTags('#my-project')).toEqual(['my-project']);
});
it('should handle tags with underscores', () => {
expect(extractTags('#my_project')).toEqual(['my_project']);
});
it('should return empty array for no tags', () => {
expect(extractTags('No hashtags here')).toEqual([]);
});
it('should return empty array for empty string', () => {
expect(extractTags('')).toEqual([]);
});
it('should not match hashtags inside code blocks', () => {
expect(extractTags('```\n#not-a-tag\n```')).toEqual([]);
});
it('should not match hashtags inside inline code', () => {
expect(extractTags('Use `#channel` for that')).toEqual([]);
});
it('should not match hashtags in URLs', () => {
expect(extractTags('Visit https://example.com#section')).toEqual([]);
});
it('should handle tags at start of line', () => {
expect(extractTags('#first tag')).toEqual(['first']);
});
it('should handle mixed content with code and tags', () => {
const content = 'My #project uses `#define` and has a #deadline';
expect(extractTags(content)).toEqual(['project', 'deadline']);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Extract #hashtags from text content.
* Matches hashtags that start with # followed by word characters.
* Does not match inside code blocks or URLs.
*/
export function extractTags(content: string): string[] {
if (!content) return [];
// Remove code blocks (``` ... ```) to avoid matching inside them
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
// Remove inline code (` ... `)
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
// Remove URLs
const withoutUrls = withoutInlineCode.replace(/https?:\/\/\S+/g, '');
const matches = withoutUrls.match(/(?:^|\s)#([\w-]+)/g);
if (!matches) return [];
const tags = matches.map((m) => m.trim().slice(1).toLowerCase());
return [...new Set(tags)];
}
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import '../app.css';
import Header from '$lib/components/Layout/Header.svelte';
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import '$lib/stores/theme.js';
import { onMount } from 'svelte';
let { children } = $props();
let sidebarOpen = $state(false);
onMount(() => {
loadNotes();
});
</script>
<div class="min-h-screen bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} />
<main class="pt-4 transition-all lg:ml-64">
<div class="mx-auto max-w-7xl px-4">
{@render children()}
</div>
</main>
<Toast />
</div>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
function openEditor(note: Note) {
editingNote = note;
}
function closeEditor() {
editingNote = null;
showNewNote = false;
}
</script>
<svelte:head>
<title>Crumbs</title>
</svelte:head>
{#if $currentFilter === 'all'}
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-left text-sm text-gray-500 shadow-sm transition-shadow hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
data-testid="new-note-btn"
>
Take a note...
</button>
</div>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if $currentFilter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-gray-600 dark:text-gray-400">Archive</h2>
{:else if $currentFilter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-gray-600 dark:text-gray-400">Trash</h2>
{/if}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
</div>
{/if}
<NoteGrid
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
/>
{#if $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-600">
<svg class="mb-4 h-24 w-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p class="text-lg">
{#if $currentFilter === 'trashed'}
No notes in trash
{:else if $currentFilter === 'archived'}
No archived notes
{:else if $selectedTag}
No notes with tag #{$selectedTag}
{:else}
Notes you add appear here
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
+26
View File
@@ -0,0 +1,26 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
const { password } = await request.json();
if (!password) {
throw error(400, 'Password is required');
}
const valid = await verifyPassword(password);
if (!valid) {
throw error(401, 'Invalid password');
}
const token = await createSession();
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 // 30 days
});
return json({ success: true });
};
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { deleteSession } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get('session');
if (token) {
await deleteSession(token);
}
cookies.delete('session', { path: '/' });
return json({ success: true });
};
+31
View File
@@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { setupUser, createSession, isSetupComplete } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
const alreadySetup = await isSetupComplete();
if (alreadySetup) {
throw error(400, 'Setup already completed');
}
const { password } = await request.json();
if (!password || password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const success = await setupUser(password);
if (!success) {
throw error(500, 'Failed to create user');
}
const token = await createSession();
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60
});
return json({ success: true }, { status: 201 });
};
+89
View File
@@ -0,0 +1,89 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
export const GET: RequestHandler = async ({ url }) => {
const filter = url.searchParams.get('filter') || 'all';
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = eq(notes.trashed, true);
break;
default:
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
}
const result = await db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
// Fetch tags for each note
const notesWithTags = await Promise.all(
result.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
return json(notesWithTags);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const now = new Date();
const id = body.id || uuidv4();
const newNote = {
id,
title: body.title || '',
content: body.content || '',
color: body.color || 'default',
pinned: body.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: body.checklistMode || false,
sortOrder: body.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
await db.insert(notes).values(newNote);
// Extract and save tags
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(id, extractedTags);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
// Remove existing tag associations
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
// Upsert tag
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
export const GET: RequestHandler = async ({ params }) => {
const note = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!note) {
throw error(404, 'Note not found');
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return json({ ...note, tags: tagRows.map((t) => t.name) });
};
export const PATCH: RequestHandler = async ({ params, request }) => {
const body = await request.json();
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
}
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
if (body.title !== undefined) updates.title = body.title;
if (body.content !== undefined) updates.content = body.content;
if (body.color !== undefined) updates.color = body.color;
if (body.pinned !== undefined) updates.pinned = body.pinned;
if (body.archived !== undefined) updates.archived = body.archived;
if (body.trashed !== undefined) {
updates.trashed = body.trashed;
updates.trashedAt = body.trashed ? now : null;
}
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
await db.update(notes).set(updates).where(eq(notes.id, params.id));
const updated = await db.select().from(notes).where(eq(notes.id, params.id)).get();
// Re-sync tags if content or title changed
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(params.id, extractedTags);
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, params.id));
return json({ ...updated, tags: tagRows.map((t) => t.name) });
};
export const DELETE: RequestHandler = async ({ params }) => {
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
}
await db.delete(notes).where(eq(notes.id, params.id));
return json({ success: true });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
@@ -0,0 +1,53 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
export const GET: RequestHandler = async ({ params, url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId);
if (!attachment) throw error(404, 'Attachment not found');
const buffer = await readFile(attachment.path);
return new Response(buffer, {
headers: {
'Content-Type': attachment.mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`
}
});
}
const list = await getAttachmentsByNote(params.id);
return json(list);
};
export const POST: RequestHandler = async ({ params, request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
throw error(400, 'No file provided');
}
if (!file.type.startsWith('image/')) {
throw error(400, 'Only image files are allowed');
}
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw error(400, 'File too large (max 10MB)');
}
const attachment = await saveAttachment(params.id, file);
return json(attachment, { status: 201 });
};
export const DELETE: RequestHandler = async ({ url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId);
return json({ success: true });
};
+65
View File
@@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q')?.trim();
if (!query) {
return json([]);
}
const pattern = `%${query}%`;
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
// Also search by tag name
const tagResults = await db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, pattern));
const tagNoteIds = new Set(tagResults.map((r) => r.noteId));
const allNoteIds = new Set(results.map((n) => n.id));
// Fetch tag-matched notes not already in results
const extraNoteIds = [...tagNoteIds].filter((id) => !allNoteIds.has(id));
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
for (const id of extraNoteIds) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.trashed, false)))
.get();
if (note) extraNotes.push(note);
}
}
const combined = [...results, ...extraNotes];
// Attach tags to each note
const withTags = await Promise.all(
combined.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
return json(withTags);
};
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
export const POST: RequestHandler = async ({ request }) => {
const { changes } = await request.json();
if (!Array.isArray(changes)) {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url }) => {
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since);
return json(changes);
};
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { tags } from '$lib/server/db/schema.js';
export const GET: RequestHandler = async () => {
const allTags = await db.select().from(tags);
return json(allTags);
};
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
loading = true;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Login - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md dark:bg-gray-800">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Crumbs</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter your password to continue</p>
</div>
<form onsubmit={handleLogin}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="password-input"
required
/>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
data-testid="login-btn"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleSetup(e: Event) {
e.preventDefault();
errorMsg = '';
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
}
if (password !== confirmPassword) {
errorMsg = 'Passwords do not match';
return;
}
loading = true;
try {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Setup - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md dark:bg-gray-800">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Welcome to Crumbs</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set up your password to get started</p>
</div>
<form onsubmit={handleSetup}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password (min 8 characters)"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="password-input"
required
/>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="confirm-password-input"
required
/>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
data-testid="setup-btn"
>
{loading ? 'Setting up...' : 'Set password & start'}
</button>
</form>
</div>
</div>