mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat: add user preferences system
Implement 4 user preferences (default note mode, default note color, hide footer, sidebar default state) with offline-first localStorage backed by server-side key-value storage. - Add user_preferences table (EAV pattern with unique user_id+key index) - Create server service with get/upsert operations - Add GET/PUT /api/preferences endpoints - Build Svelte 5 runes-based client store (.svelte.ts) - Wire preferences into app layout (footer toggle, sidebar state) - Wire default note mode/color into NoteEditor for new notes - Add Preferences tab as first item in Settings navigation - Add 7 unit tests and 4 E2E tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
664db9eb8d
commit
93a632e78a
@@ -0,0 +1,114 @@
|
|||||||
|
CREATE TABLE `api_keys` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`key_hash` text NOT NULL,
|
||||||
|
`key_prefix` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`last_used_at` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `note_collaborators` (
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`added_by` integer NOT NULL,
|
||||||
|
`added_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `note_user_state` (
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`pinned` integer DEFAULT false NOT NULL,
|
||||||
|
`archived` integer DEFAULT false NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_preferences` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_attachments` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`mime_type` text NOT NULL,
|
||||||
|
`size` integer NOT NULL,
|
||||||
|
`path` text NOT NULL,
|
||||||
|
`thumbnail_path` text,
|
||||||
|
`featured` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
|
||||||
|
DROP TABLE `attachments`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_notes` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`title` text DEFAULT '' NOT NULL,
|
||||||
|
`content` text DEFAULT '' NOT NULL,
|
||||||
|
`color` text DEFAULT 'default' NOT NULL,
|
||||||
|
`pinned` integer DEFAULT false NOT NULL,
|
||||||
|
`archived` integer DEFAULT false NOT NULL,
|
||||||
|
`trashed` integer DEFAULT false NOT NULL,
|
||||||
|
`trashed_at` integer,
|
||||||
|
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`version` integer DEFAULT 1 NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
|
||||||
|
DROP TABLE `notes`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_sync_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`operation` text NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
|
||||||
|
DROP TABLE `sync_log`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_tags` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
|
||||||
|
DROP TABLE `tags`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1773016788899,
|
"when": 1773016788899,
|
||||||
"tag": "0001_strange_lord_hawal",
|
"tag": "0001_strange_lord_hawal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773150888301,
|
||||||
|
"tag": "0002_deep_black_widow",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||||
import { notes } from '$lib/stores/notes.js';
|
import { notes } from '$lib/stores/notes.js';
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||||
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||||
import Palette from 'lucide-svelte/icons/palette';
|
import Palette from 'lucide-svelte/icons/palette';
|
||||||
@@ -26,19 +27,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { note, isNew = false, onClose }: Props = $props();
|
const { note, isNew = false, onClose }: Props = $props();
|
||||||
|
const prefs = getPreferences();
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let title = $state(note?.title ?? '');
|
let title = $state(note?.title ?? '');
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let content = $state(note?.content ?? '');
|
let content = $state(note?.content ?? '');
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let color = $state<NoteColor>(note?.color ?? 'default');
|
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let checklistMode = $state(note?.checklistMode ?? false);
|
let checklistMode = $state(note?.checklistMode ?? false);
|
||||||
let showColorPicker = $state(false);
|
let showColorPicker = $state(false);
|
||||||
let showImageUpload = $state(false);
|
let showImageUpload = $state(false);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
|
||||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||||
let tiptapEditor: Editor | undefined = $state();
|
let tiptapEditor: Editor | undefined = $state();
|
||||||
let editorTick = $state(0);
|
let editorTick = $state(0);
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ sqlite.exec(`
|
|||||||
success INTEGER NOT NULL,
|
success INTEGER NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migration: detect old schema (no email column on users) and add columns
|
// Migration: detect old schema (no email column on users) and add columns
|
||||||
@@ -186,6 +194,7 @@ sqlite.exec(`
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||||
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -175,3 +175,17 @@ export const loginAttempts = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const userPreferences = sqliteTable(
|
||||||
|
'user_preferences',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
key: text('key').notNull(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
|
||||||
|
);
|
||||||
|
|||||||
@@ -127,6 +127,15 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_used_at INTEGER
|
last_used_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { getPreferences, upsertPreferences } from './preferences.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const USER_ID = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb({ seedUser: true });
|
||||||
|
db = testDb.db;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPreferences', () => {
|
||||||
|
it('should return empty object for user with no preferences', () => {
|
||||||
|
const prefs = getPreferences(db, USER_ID);
|
||||||
|
expect(prefs).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertPreferences', () => {
|
||||||
|
it('should insert new preferences', () => {
|
||||||
|
const result = upsertPreferences(db, USER_ID, {
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing preferences', () => {
|
||||||
|
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||||
|
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
|
||||||
|
expect(result.defaultNoteMode).toBe('richtext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial updates without affecting other keys', () => {
|
||||||
|
upsertPreferences(db, USER_ID, {
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
|
||||||
|
expect(result).toEqual({
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'false'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep preferences isolated between users', () => {
|
||||||
|
// Create a second user
|
||||||
|
db.run(
|
||||||
|
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
|
||||||
|
);
|
||||||
|
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||||
|
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
|
||||||
|
|
||||||
|
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
|
||||||
|
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { userPreferences } from './db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferences for a user as a key-value record.
|
||||||
|
*/
|
||||||
|
export function getPreferences(db: Db, userId: number): Record<string, string> {
|
||||||
|
const rows = db
|
||||||
|
.select({ key: userPreferences.key, value: userPreferences.value })
|
||||||
|
.from(userPreferences)
|
||||||
|
.where(eq(userPreferences.userId, userId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.key] = row.value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert multiple preferences for a user.
|
||||||
|
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
|
||||||
|
*/
|
||||||
|
export function upsertPreferences(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
prefs: Record<string, string>
|
||||||
|
): Record<string, string> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const [key, value] of Object.entries(prefs)) {
|
||||||
|
db.insert(userPreferences)
|
||||||
|
.values({ userId, key, value, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userPreferences.userId, userPreferences.key],
|
||||||
|
set: { value, updatedAt: now }
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
return getPreferences(db, userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||||
|
import type { UserPreferences } from '$lib/types/preferences.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'crumbs-preferences';
|
||||||
|
|
||||||
|
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
|
||||||
|
|
||||||
|
function loadFromLocalStorage(): UserPreferences {
|
||||||
|
if (!browser) return { ...DEFAULT_PREFERENCES };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupted localStorage — use defaults
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_PREFERENCES };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(prefs: UserPreferences) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// localStorage full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert server key-value record to typed UserPreferences */
|
||||||
|
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
|
||||||
|
const result: Partial<UserPreferences> = {};
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (key in DEFAULT_PREFERENCES) {
|
||||||
|
const k = key as keyof UserPreferences;
|
||||||
|
if (BOOLEAN_PREF_KEYS.has(k)) {
|
||||||
|
(result as Record<string, unknown>)[k] = value === 'true';
|
||||||
|
} else {
|
||||||
|
(result as Record<string, unknown>)[k] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert UserPreferences to server key-value record */
|
||||||
|
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(prefs)) {
|
||||||
|
result[key] = String(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize preferences — load from localStorage, then sync from server.
|
||||||
|
*/
|
||||||
|
export function initPreferences() {
|
||||||
|
preferences = loadFromLocalStorage();
|
||||||
|
syncPreferencesFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reactive preferences object.
|
||||||
|
*/
|
||||||
|
export function getPreferences(): UserPreferences {
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single preference. Saves to localStorage and pushes to server.
|
||||||
|
*/
|
||||||
|
export function updatePreference<K extends keyof UserPreferences>(
|
||||||
|
key: K,
|
||||||
|
value: UserPreferences[K]
|
||||||
|
) {
|
||||||
|
preferences = { ...preferences, [key]: value };
|
||||||
|
saveToLocalStorage(preferences);
|
||||||
|
pushToServer({ [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull preferences from server and merge into local state.
|
||||||
|
*/
|
||||||
|
export async function syncPreferencesFromServer() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/preferences');
|
||||||
|
if (res.ok) {
|
||||||
|
const serverPrefs: Record<string, string> = await res.json();
|
||||||
|
const parsed = fromServerRecord(serverPrefs);
|
||||||
|
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
|
||||||
|
saveToLocalStorage(preferences);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Offline — local state stands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push preference changes to server */
|
||||||
|
async function pushToServer(partial: Partial<UserPreferences>) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(toServerRecord(partial))
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Offline — localStorage already updated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||||
|
|
||||||
|
describe('preferences defaults', () => {
|
||||||
|
it('should have correct default values', () => {
|
||||||
|
expect(DEFAULT_PREFERENCES).toEqual({
|
||||||
|
defaultNoteMode: 'richtext',
|
||||||
|
defaultNoteColor: 'default',
|
||||||
|
hideFooter: false,
|
||||||
|
sidebarDefaultState: 'open'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify boolean preference keys', () => {
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,3 +97,6 @@ export type NoteColor =
|
|||||||
export type NoteFilter = 'all' | 'archived' | 'trashed';
|
export type NoteFilter = 'all' | 'archived' | 'trashed';
|
||||||
|
|
||||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||||
|
|
||||||
|
export type { UserPreferences } from './preferences.js';
|
||||||
|
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NoteColor } from './index.js';
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
defaultNoteMode: 'richtext' | 'markdown';
|
||||||
|
defaultNoteColor: NoteColor;
|
||||||
|
hideFooter: boolean;
|
||||||
|
sidebarDefaultState: 'open' | 'collapsed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
|
defaultNoteMode: 'richtext',
|
||||||
|
defaultNoteColor: 'default',
|
||||||
|
hideFooter: false,
|
||||||
|
sidebarDefaultState: 'open'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||||
|
'hideFooter'
|
||||||
|
]);
|
||||||
@@ -5,13 +5,21 @@
|
|||||||
import { loadNotes } from '$lib/stores/notes.js';
|
import { loadNotes } from '$lib/stores/notes.js';
|
||||||
import { startSync, stopSync } from '$lib/sync/client.js';
|
import { startSync, stopSync } from '$lib/sync/client.js';
|
||||||
import { initDb } from '$lib/sync/idb.js';
|
import { initDb } from '$lib/sync/idb.js';
|
||||||
|
import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
|
const prefs = $derived(getPreferences());
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
initPreferences();
|
||||||
|
const prefState = getPreferences();
|
||||||
|
if (prefState.sidebarDefaultState === 'collapsed') {
|
||||||
|
sidebarOpen = false;
|
||||||
|
} else {
|
||||||
|
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
|
}
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
initDb(data.user.id);
|
initDb(data.user.id);
|
||||||
}
|
}
|
||||||
@@ -34,9 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
{#if !prefs.hideFooter}
|
||||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}" data-testid="app-footer">
|
||||||
</footer>
|
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
|
import { User, Cpu, ShieldCheck, SlidersHorizontal } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let pathname = $derived(page.url.pathname);
|
let pathname = $derived(page.url.pathname);
|
||||||
@@ -17,6 +17,16 @@
|
|||||||
<!-- Sidebar nav -->
|
<!-- Sidebar nav -->
|
||||||
<nav class="w-full shrink-0 md:w-48">
|
<nav class="w-full shrink-0 md:w-48">
|
||||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/settings/preferences"
|
||||||
|
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||||
|
data-testid="settings-nav-preferences"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} />
|
||||||
|
Preferences
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ColorPicker from '$lib/components/ColorPicker.svelte';
|
||||||
|
import { getPreferences, updatePreference } from '$lib/stores/preferences.svelte.js';
|
||||||
|
import type { NoteColor } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
const prefs = $derived(getPreferences());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
|
||||||
|
|
||||||
|
<!-- Default note mode -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Default note mode">
|
||||||
|
<button
|
||||||
|
onclick={() => updatePreference('defaultNoteMode', 'richtext')}
|
||||||
|
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'richtext' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||||
|
data-testid="pref-mode-richtext"
|
||||||
|
>
|
||||||
|
Rich text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => updatePreference('defaultNoteMode', 'markdown')}
|
||||||
|
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'markdown' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||||
|
data-testid="pref-mode-markdown"
|
||||||
|
>
|
||||||
|
Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default note color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="block text-sm font-medium text-[var(--text)]">Default note color</span>
|
||||||
|
<ColorPicker
|
||||||
|
selected={prefs.defaultNoteColor}
|
||||||
|
onSelect={(color: NoteColor) => updatePreference('defaultNoteColor', color)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hide footer -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="hide-footer"
|
||||||
|
checked={prefs.hideFooter}
|
||||||
|
onchange={() => updatePreference('hideFooter', !prefs.hideFooter)}
|
||||||
|
class="h-4 w-4 rounded-sm"
|
||||||
|
data-testid="pref-hide-footer"
|
||||||
|
/>
|
||||||
|
<label for="hide-footer" class="text-sm text-[var(--text)]">Hide footer</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar default state -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sidebar-default"
|
||||||
|
checked={prefs.sidebarDefaultState === 'collapsed'}
|
||||||
|
onchange={() => updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')}
|
||||||
|
class="h-4 w-4 rounded-sm"
|
||||||
|
data-testid="pref-sidebar-default"
|
||||||
|
/>
|
||||||
|
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { getUserId } from '$lib/server/api-utils.js';
|
||||||
|
import { getPreferences, upsertPreferences } from '$lib/server/preferences.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const userId = getUserId(event);
|
||||||
|
const prefs = getPreferences(db, userId);
|
||||||
|
return json(prefs);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||||
|
const userId = getUserId(event);
|
||||||
|
const body: Record<string, string> = await request.json();
|
||||||
|
const updated = upsertPreferences(db, userId, body);
|
||||||
|
return json(updated);
|
||||||
|
};
|
||||||
@@ -1,5 +1,73 @@
|
|||||||
import { test, expect } from './helpers/fixtures.js';
|
import { test, expect } from './helpers/fixtures.js';
|
||||||
|
|
||||||
|
test.describe('Settings — Preferences', () => {
|
||||||
|
test('Scenario: Preferences tab is visible in settings nav', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// When the user navigates to settings
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
// Then the Preferences nav link is visible
|
||||||
|
await expect(page.getByTestId('settings-nav-preferences')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Default note mode change applies to new notes', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given the user sets default note mode to Markdown
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||||
|
await page.getByTestId('pref-mode-markdown').click();
|
||||||
|
await putResponse;
|
||||||
|
|
||||||
|
// When the user creates a new note
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
|
||||||
|
// Then the note editor opens in markdown mode (textarea visible)
|
||||||
|
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Footer toggle hides and shows footer', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given the footer is visible
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||||
|
|
||||||
|
// When the user enables the hide footer preference
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-hide-footer').check();
|
||||||
|
|
||||||
|
// Then the footer is hidden
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
||||||
|
|
||||||
|
// When the user disables the hide footer preference
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-hide-footer').uncheck();
|
||||||
|
|
||||||
|
// Then the footer is visible again
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Preferences persist across page reload', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given the user changes default note mode to Markdown
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-mode-markdown').click();
|
||||||
|
|
||||||
|
// When the page is reloaded
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Then the Markdown button is still selected (has primary styling)
|
||||||
|
const mdBtn = page.getByTestId('pref-mode-markdown');
|
||||||
|
await expect(mdBtn).toHaveClass(/font-medium/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('Settings — API Key Management', () => {
|
test.describe('Settings — API Key Management', () => {
|
||||||
test('Scenario: Created API key appears in the keys list', async ({
|
test('Scenario: Created API key appears in the keys list', async ({
|
||||||
authenticatedPage: page
|
authenticatedPage: page
|
||||||
|
|||||||
Reference in New Issue
Block a user