feat: add notes API and context service
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
import { triplit, Query } from '../triplit/client';
|
||||||
|
import type { Note } from '../triplit/schema';
|
||||||
|
|
||||||
|
export type CreateNoteData = Omit<Note, 'id' | 'createdAt' | 'updatedAt' | 'pinned' | 'archived' | 'deleted' | 'deletedAt'> & {
|
||||||
|
pinned?: boolean;
|
||||||
|
archived?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNoteData = Partial<Omit<Note, 'id' | 'userId' | 'createdAt'>>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<Note[]>([]);
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state<string | null>(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<CreateNoteData, 'userId'>) {
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<ReturnType<typeof setNoteService>>(NOTE_SERVICE_KEY);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user