mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Add notesLoaded store flag set after IDB read completes. Gate the empty state message behind it so it only shows once we know the store is empty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
5.8 KiB
TypeScript
200 lines
5.8 KiB
TypeScript
import { writable, derived } from 'svelte/store';
|
|
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
|
|
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
|
import { showToast } from '$lib/stores/toast.js';
|
|
import { extractTags } from '$lib/utils/tags.js';
|
|
|
|
export const notes = writable<Note[]>([]);
|
|
export const currentFilter = writable<NoteFilter>('all');
|
|
export const selectedTag = writable<string | null>(null);
|
|
export const notesLoaded = writable(false);
|
|
|
|
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();
|
|
});
|
|
|
|
function isNetworkError(err: unknown): boolean {
|
|
return err instanceof TypeError;
|
|
}
|
|
|
|
export async function loadNotes(filter: NoteFilter = 'all') {
|
|
// Load from IDB first for instant display
|
|
const cached = await getAllNotes();
|
|
if (cached.length > 0) {
|
|
notes.set(cached);
|
|
}
|
|
currentFilter.set(filter);
|
|
notesLoaded.set(true);
|
|
|
|
// Then fetch from server in the background
|
|
try {
|
|
const res = await fetch(`/api/notes?filter=${filter}`);
|
|
if (res.ok) {
|
|
const data: Note[] = await res.json();
|
|
notes.set(data);
|
|
currentFilter.set(filter);
|
|
|
|
// Update IDB with server state
|
|
await clearIdbNotes();
|
|
await Promise.all(data.map((n) => putNote(n)));
|
|
}
|
|
} catch {
|
|
// Offline — IDB data already loaded above
|
|
}
|
|
}
|
|
|
|
export async function createNote(note: NoteCreate): Promise<Note | null> {
|
|
try {
|
|
const res = await fetch('/api/notes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(note)
|
|
});
|
|
if (res.ok) {
|
|
const created: Note = await res.json();
|
|
notes.update((list) => [created, ...list]);
|
|
await putNote(created);
|
|
return created;
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
if (isNetworkError(err)) {
|
|
const now = new Date();
|
|
const title = note.title ?? '';
|
|
const content = note.content ?? '';
|
|
const optimistic: Note = {
|
|
id: crypto.randomUUID(),
|
|
title,
|
|
content,
|
|
color: note.color ?? 'default',
|
|
pinned: note.pinned ?? false,
|
|
archived: false,
|
|
trashed: false,
|
|
trashedAt: null,
|
|
checklistMode: note.checklistMode ?? false,
|
|
sortOrder: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
version: 1,
|
|
tags: extractTags(`${title} ${content}`)
|
|
};
|
|
notes.update((list) => [optimistic, ...list]);
|
|
await putNote(optimistic);
|
|
await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() });
|
|
showToast('Saved offline — will sync when reconnected', 'info');
|
|
return optimistic;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates)
|
|
});
|
|
if (res.ok) {
|
|
const updated: Note = await res.json();
|
|
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
|
await putNote(updated);
|
|
return updated;
|
|
}
|
|
showToast('Failed to save note', 'error');
|
|
return null;
|
|
} catch (err) {
|
|
if (isNetworkError(err)) {
|
|
let optimistic: Note | null = null;
|
|
notes.update((list) =>
|
|
list.map((n) => {
|
|
if (n.id === id) {
|
|
optimistic = { ...n, ...updates, updatedAt: new Date() };
|
|
if (updates.title !== undefined || updates.content !== undefined) {
|
|
optimistic.tags = extractTags(`${optimistic.title} ${optimistic.content}`);
|
|
}
|
|
return optimistic;
|
|
}
|
|
return n;
|
|
})
|
|
);
|
|
if (optimistic) {
|
|
await putNote(optimistic);
|
|
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
|
showToast('Saved offline — will sync when reconnected', 'info');
|
|
}
|
|
return optimistic;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function deleteNote(id: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
notes.update((list) => list.filter((n) => n.id !== id));
|
|
await deleteNoteFromIdb(id);
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (err) {
|
|
if (isNetworkError(err)) {
|
|
notes.update((list) => list.filter((n) => n.id !== id));
|
|
await deleteNoteFromIdb(id);
|
|
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
|
showToast('Saved offline — will sync when reconnected', 'info');
|
|
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 });
|
|
}
|