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