diff --git a/src/lib/api/notes.triplit.ts b/src/lib/api/notes.triplit.ts new file mode 100644 index 0000000..299ca12 --- /dev/null +++ b/src/lib/api/notes.triplit.ts @@ -0,0 +1,136 @@ +import { triplit, Query } from '../triplit/client'; +import type { Note } from '../triplit/schema'; + +export type CreateNoteData = Omit & { + pinned?: boolean; + archived?: boolean; +}; + +export type UpdateNoteData = Partial>; + +export async function createNote(data: CreateNoteData) { + return await triplit.insert('notes', { + userId: data.userId, + title: data.title || '', + content: data.content || '', + pinned: data.pinned ?? false, + archived: data.archived ?? false, + deleted: false, + tags: data.tags || new Set(), + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }); +} + +export async function updateNote(id: string, data: UpdateNoteData) { + return await triplit.update('notes', id, { + ...data, + updatedAt: new Date(), + }); +} + +export async function deleteNote(id: string) { + return await triplit.update('notes', id, { + deleted: true, + deletedAt: new Date(), + updatedAt: new Date(), + }); +} + +export async function restoreNote(id: string) { + return await triplit.update('notes', id, { + deleted: false, + deletedAt: null, + updatedAt: new Date(), + }); +} + +export async function permanentDelete(id: string) { + return await triplit.delete('notes', id); +} + +export async function archiveNote(id: string, archived: boolean = true) { + return await triplit.update('notes', id, { + archived, + updatedAt: new Date(), + }); +} + +export async function pinNote(id: string, pinned: boolean = true) { + return await triplit.update('notes', id, { + pinned, + updatedAt: new Date(), + }); +} + +export async function addTag(id: string, tag: string) { + const note = await triplit.fetchById('notes', id); + if (!note) return null; + + const tags = new Set(note.tags); + tags.add(tag); + + return await triplit.update('notes', id, { + tags, + updatedAt: new Date(), + }); +} + +export async function removeTag(id: string, tag: string) { + const note = await triplit.fetchById('notes', id); + if (!note) return null; + + const tags = new Set(note.tags); + tags.delete(tag); + + return await triplit.update('notes', id, { + tags, + updatedAt: new Date(), + }); +} + +export function getNotes(userId: string) { + return Query('notes') + .Where('userId', '=', userId) + .Where('deleted', '=', false) + .Order('updatedAt', 'DESC'); +} + +export function getArchivedNotes(userId: string) { + return Query('notes') + .Where('userId', '=', userId) + .Where('archived', '=', true) + .Where('deleted', '=', false) + .Order('updatedAt', 'DESC'); +} + +export function getTrashedNotes(userId: string) { + return Query('notes') + .Where('userId', '=', userId) + .Where('deleted', '=', true) + .Order('deletedAt', 'DESC'); +} + +export function getNote(id: string) { + return Query('notes') + .Where('id', '=', id); +} + +export async function emptyTrash(userId: string) { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const trashedNotes = await triplit.fetch( + Query('notes') + .Where('userId', '=', userId) + .Where('deleted', '=', true) + .Where('deletedAt', '<=', thirtyDaysAgo) + ); + + for (const note of trashedNotes) { + await triplit.delete('notes', note.id); + } + + return trashedNotes.length; +} diff --git a/src/lib/context/notes.svelte.ts b/src/lib/context/notes.svelte.ts new file mode 100644 index 0000000..2ba3dc6 --- /dev/null +++ b/src/lib/context/notes.svelte.ts @@ -0,0 +1,199 @@ +import { getContext, onDestroy, setContext } from 'svelte'; +import type { Note } from '../triplit/schema'; +import type { CreateNoteData, UpdateNoteData } from '../api/notes.triplit'; +import * as notesApi from '../api/notes.triplit'; +import { triplit } from '../triplit/client'; + +export class NoteService { + notes = $state([]); + loading = $state(true); + error = $state(null); + + private notesUnsubscribe?: () => void; + + // Derived state - computed values using $derived + activeNotes = $derived( + this.notes.filter(n => !n.deleted && !n.archived) + .sort((a, b) => { + // Pinned notes first, then by updatedAt + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }) + ); + + pinnedNotes = $derived( + this.activeNotes.filter(n => n.pinned) + ); + + archivedNotes = $derived( + this.notes.filter(n => n.archived && !n.deleted) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + ); + + trashedNotes = $derived( + this.notes.filter(n => n.deleted) + .sort((a, b) => { + const aTime = a.deletedAt?.getTime() || 0; + const bTime = b.deletedAt?.getTime() || 0; + return bTime - aTime; + }) + ); + + constructor(private userId: string) { + this.setupSubscription(); + + onDestroy(() => { + this.notesUnsubscribe?.(); + }); + } + + private setupSubscription() { + this.notesUnsubscribe = triplit.subscribe( + notesApi.getNotes(this.userId), + (results) => { + this.notes = Array.from(results); + this.loading = false; + this.error = null; + }, + (err) => { + console.error('Error in notes subscription:', err); + this.error = 'Failed to load notes'; + this.loading = false; + } + ); + } + + async createNote(data: Omit) { + try { + return await notesApi.createNote({ ...data, userId: this.userId }); + } catch (err) { + console.error('Failed to create note:', err); + throw err; + } + } + + async updateNote(id: string, data: UpdateNoteData) { + try { + return await notesApi.updateNote(id, data); + } catch (err) { + console.error('Failed to update note:', err); + throw err; + } + } + + async deleteNote(id: string) { + try { + return await notesApi.deleteNote(id); + } catch (err) { + console.error('Failed to delete note:', err); + throw err; + } + } + + async restoreNote(id: string) { + try { + return await notesApi.restoreNote(id); + } catch (err) { + console.error('Failed to restore note:', err); + throw err; + } + } + + async permanentDelete(id: string) { + try { + return await notesApi.permanentDelete(id); + } catch (err) { + console.error('Failed to permanently delete note:', err); + throw err; + } + } + + async archiveNote(id: string, archived: boolean = true) { + try { + return await notesApi.archiveNote(id, archived); + } catch (err) { + console.error('Failed to archive note:', err); + throw err; + } + } + + async pinNote(id: string, pinned: boolean = true) { + try { + return await notesApi.pinNote(id, pinned); + } catch (err) { + console.error('Failed to pin note:', err); + throw err; + } + } + + async addTag(id: string, tag: string) { + try { + return await notesApi.addTag(id, tag); + } catch (err) { + console.error('Failed to add tag:', err); + throw err; + } + } + + async removeTag(id: string, tag: string) { + try { + return await notesApi.removeTag(id, tag); + } catch (err) { + console.error('Failed to remove tag:', err); + throw err; + } + } + + async emptyTrash() { + try { + return await notesApi.emptyTrash(this.userId); + } catch (err) { + console.error('Failed to empty trash:', err); + throw err; + } + } + + searchNotes(query: string, notesArray: Note[] = this.activeNotes): Note[] { + if (!query.trim()) return notesArray; + + const searchTerm = query.toLowerCase(); + + return notesArray.filter(note => { + // Search in title and content + const titleMatch = note.title.toLowerCase().includes(searchTerm); + const contentMatch = note.content.toLowerCase().includes(searchTerm); + + // Search in tags + const tagMatch = Array.from(note.tags).some(tag => + tag.toLowerCase().includes(searchTerm) + ); + + return titleMatch || contentMatch || tagMatch; + }); + } + + getNoteById(id: string): Note | undefined { + return this.notes.find(n => n.id === id); + } + + getAllTags(): string[] { + const tagSet = new Set(); + + this.notes.forEach(note => { + note.tags.forEach(tag => tagSet.add(tag)); + }); + + return Array.from(tagSet).sort(); + } +} + +const NOTE_SERVICE_KEY = Symbol('NOTE_SERVICE'); + +export function setNoteService(userId: string) { + return setContext(NOTE_SERVICE_KEY, new NoteService(userId)); +} + +export function getNoteService() { + return getContext>(NOTE_SERVICE_KEY); +}