mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f5bdcbd8 | ||
|
|
b6dbdbe9a5 | ||
|
|
21efb1d811 | ||
|
|
e2ee18893f | ||
|
|
be53b4b26e | ||
|
|
042c4c06e5 | ||
|
|
9d1c898b84 | ||
|
|
07b27c01e7 | ||
|
|
4f4cc69b9f | ||
|
|
3f81f22026 | ||
|
|
9331351c6d |
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.25.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.27.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
|
||||
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
export const ADMIN = {
|
||||
email: 'admin@bretzel.app',
|
||||
displayName: 'Bretzel Admin',
|
||||
password: 'bretzel2026!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
export const COLLABORATOR = {
|
||||
email: 'salzig@bretzel.app',
|
||||
displayName: 'Salzig Bretzel',
|
||||
password: 'salzig2026!!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
+3
-1
@@ -143,7 +143,8 @@ body::before {
|
||||
}
|
||||
|
||||
/* Nested task lists take full width below the parent item */
|
||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"] {
|
||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||
.prose ul.task-list li > ul.task-list {
|
||||
flex-basis: 100%;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
@@ -166,6 +167,7 @@ body::before {
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,100 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// Auto-save: track last-saved state to detect real changes
|
||||
let lastSavedTitle = note?.title ?? '';
|
||||
let lastSavedContent = note?.content ?? '';
|
||||
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||
let isSaving = false;
|
||||
let savingPromise: Promise<void> | null = null;
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||
// from the original. Track whether auto-save should be active yet.
|
||||
let autoSaveReady = isNew;
|
||||
|
||||
function hasUnsavedChanges(): boolean {
|
||||
return (
|
||||
title !== lastSavedTitle ||
|
||||
content !== lastSavedContent ||
|
||||
color !== lastSavedColor ||
|
||||
checklistMode !== lastSavedChecklistMode
|
||||
);
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (isSaving) return;
|
||||
// Snapshot current fields to avoid races with user edits during await
|
||||
const snap = { title, content, color, checklistMode };
|
||||
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||
if (
|
||||
snap.title === lastSavedTitle &&
|
||||
snap.content === lastSavedContent &&
|
||||
snap.color === lastSavedColor &&
|
||||
snap.checklistMode === lastSavedChecklistMode
|
||||
) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
if (currentlyNew) {
|
||||
const created = await createNote(snap);
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
} else if (noteId) {
|
||||
const updated = await updateNote(noteId, snap);
|
||||
if (updated) {
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: triggers 2s after the user stops editing
|
||||
$effect(() => {
|
||||
const _t = title;
|
||||
const _c = content;
|
||||
const _col = color;
|
||||
const _cm = checklistMode;
|
||||
|
||||
if (!autoSaveReady) {
|
||||
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||
lastSavedContent = _c;
|
||||
autoSaveReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||
if (!_t.trim() && !_c.trim()) return;
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
savingPromise = performSave();
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(autoSaveTimer);
|
||||
});
|
||||
|
||||
// Safety net: save on tab close / navigation
|
||||
$effect(() => {
|
||||
function handleBeforeUnload() {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
@@ -188,7 +282,13 @@
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
const updated = [...attachmentsList, attachment];
|
||||
attachmentsList = updated;
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
@@ -200,9 +300,14 @@
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
const updated = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
attachmentsList = updated;
|
||||
// Keep the notes store in sync so the card reflects featured state
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
@@ -215,7 +320,11 @@
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
attachmentsList = updated;
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
@@ -226,11 +335,11 @@
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
});
|
||||
|
||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||
// pointer events are never synthetically re-dispatched from touch, so
|
||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||
// pass through to NoteCards underneath (ghost click).
|
||||
let pointerdownOnOverlay = false;
|
||||
let closing = $state(false);
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
@@ -241,35 +350,37 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
// Wait for any in-flight auto-save to finish
|
||||
if (savingPromise) await savingPromise;
|
||||
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
// Final save if auto-save hasn't caught up yet
|
||||
if (hasUnsavedChanges()) {
|
||||
await performSave();
|
||||
}
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
clearTimeout(autoSaveTimer);
|
||||
if (savingPromise) await savingPromise;
|
||||
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||
if (!title.trim()) title = 'Untitled';
|
||||
await performSave();
|
||||
return noteId;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
@@ -302,7 +413,7 @@
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await archiveNote(noteId);
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +421,7 @@
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await trashNote(noteId);
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,12 +440,13 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
class="fixed inset-0 z-40 {closing ? '' : 'flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
@@ -584,6 +696,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { randomBytes, randomUUID } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
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);
|
||||
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { TEST_CREDENTIALS_FILE } from './global-setup';
|
||||
|
||||
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
test.describe.serial('Authentication', () => {
|
||||
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
|
||||
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
|
||||
data: {
|
||||
email: 'user2@test.com',
|
||||
displayName: 'User Two',
|
||||
password: 'userpassword123',
|
||||
password: userPassword,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Auto-save', () => {
|
||||
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
|
||||
// When the user starts a new note and types a title and content
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-saved Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('This should be saved automatically');
|
||||
|
||||
// Then the note is persisted after the auto-save delay (2s debounce)
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And closing the editor preserves the note
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Edit Me" exists
|
||||
await createNote(page, 'Edit Me', 'Original content');
|
||||
|
||||
// When the user opens the note and changes the title
|
||||
await noteCard(page, 'Edit Me').click();
|
||||
await page.getByTestId('note-title-input').clear();
|
||||
await page.getByTestId('note-title-input').fill('Edited Title');
|
||||
|
||||
// Then the updated title is auto-saved after the debounce delay
|
||||
// Wait for the API call that confirms the save
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// And the note card reflects the change while editor is still open
|
||||
await expect(noteCard(page, 'Edited Title')).toBeVisible();
|
||||
|
||||
// Cleanup: close the editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given the current number of notes
|
||||
const noteCards = page.locator('[data-testid="note-card"]');
|
||||
const countBefore = await noteCards.count();
|
||||
|
||||
// When the user opens a new note but leaves it empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// And waits longer than the auto-save delay
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// And closes the empty editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then no new note was created
|
||||
const countAfter = await noteCards.count();
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note and waits for auto-save
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Content that survives');
|
||||
|
||||
// Wait for auto-save to fire
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// When the page is reloaded without closing the editor
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the note is still visible
|
||||
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const TEST_DB = './data/test-crumbs.db';
|
||||
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
|
||||
|
||||
export default function globalSetup() {
|
||||
// Clean test database before E2E run so each run starts fresh
|
||||
@@ -10,4 +12,14 @@ export default function globalSetup() {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random passwords for this test run, shared across all workers
|
||||
writeFileSync(
|
||||
TEST_CREDENTIALS_FILE,
|
||||
JSON.stringify({
|
||||
testPassword: `test-${randomUUID()}`,
|
||||
collabPassword: `collab-${randomUUID()}`,
|
||||
userPassword: `user-${randomUUID()}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { collectCoverage } from './coverage';
|
||||
import { TEST_CREDENTIALS_FILE } from '../global-setup';
|
||||
|
||||
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
const TEST_EMAIL = 'admin@test.com';
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
const TEST_PASSWORD = testPassword;
|
||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_PASSWORD = collabPassword;
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.26.0</span>
|
||||
<span class="footer-version">v0.27.1</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user