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 66c91df983
89 changed files with 11359 additions and 0 deletions
+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 });
};