diff --git a/AGENTS.md b/AGENTS.md index eb6cda6..a431c80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ export const [getLinkContext, setLinkContext] = createContext(); Wrap in `$derived` for reactivity. ```svelte - @@ -258,6 +296,7 @@ "flex-1 space-y-1 overflow-y-auto px-2 py-2 transition-all", isRootDropTarget && "bg-indigo-50 ring-2 ring-primary ring-inset", ]} + role="tree" > {#each notesTree as item, idx (item.id)} { + onclick={async () => { if (user === undefined) { throw new Error("Cannot create note whilst logged out."); } - unawaited( - handleCreateNote( - "An Untitled Note", - clickedId, - false, - user.publicKey, - ), + await handleCreateNote( + "An Untitled Note", + clickedId, + false, + user.publicKey, ); + closeContextMenu(); }}> New Note Inside diff --git a/src/lib/components/sidebar-context.ts b/src/lib/components/sidebar-context.ts new file mode 100644 index 0000000..a055605 --- /dev/null +++ b/src/lib/components/sidebar-context.ts @@ -0,0 +1,10 @@ +import { createContext } from "svelte"; + +interface SidebarContext { + get isCollapsed(): boolean; + + toggle: () => boolean; +} + +export const [getSidebarContext, setSidebarContext] = + createContext(); diff --git a/src/lib/loro.ts b/src/lib/loro.ts index 38521d3..b50a8ce 100644 --- a/src/lib/loro.ts +++ b/src/lib/loro.ts @@ -1,9 +1,10 @@ -import { decryptData, encryptData } from "$lib/crypto"; +import { decryptData, encryptData } from "$lib/crypto.ts"; import { syncSchemaJson } from "$lib/remote/notes.schemas.ts"; import { sync } from "$lib/remote/sync.remote.ts"; import { Chunk, Effect, Fiber, Function, PubSub, Schema, Stream } from "effect"; import diff from "fast-diff"; import { LoroDoc, type LoroText, type Frontiers } from "loro-crdt"; +import { unawaited } from "./unawaited.ts"; export type Doc = LoroDoc<{ content: LoroText; @@ -137,7 +138,7 @@ export class LoroNoteManager { for (const update of data.updates) { const updateBytes = Uint8Array.fromBase64(update); - void emit(Effect.succeed(Chunk.make(updateBytes))); + unawaited(emit(Effect.succeed(Chunk.make(updateBytes)))); } } catch (error) { console.error("Failed to process sync message:", error); diff --git a/src/lib/remote/accounts.remote.ts b/src/lib/remote/accounts.remote.ts index cdbe154..0eb376a 100644 --- a/src/lib/remote/accounts.remote.ts +++ b/src/lib/remote/accounts.remote.ts @@ -7,6 +7,8 @@ import { fail, invalid, redirect } from "@sveltejs/kit"; import { eq } from "drizzle-orm"; import { Redacted, Schema } from "effect"; import { loginSchema, signupSchema } from "./accounts.schema.ts"; +import { resolve } from "$app/paths"; +import { Temporal } from "temporal-polyfill"; export const login = form( loginSchema, @@ -41,7 +43,7 @@ export const login = form( const session = await auth.createSession(sessionToken, existingUser.id); auth.setSessionTokenCookie(cookies, sessionToken, session.expiresAt); - return redirect(302, "/"); + return redirect(302, resolve("/notes/")); }, ); @@ -66,7 +68,7 @@ export const signup = form( passwordHash, publicKey, privateKeyEncrypted, - createdAt: new Date(), + createdAt: Temporal.Now.instant(), } satisfies table.User); const sessionToken = auth.generateSessionToken(); @@ -75,7 +77,7 @@ export const signup = form( } catch { return fail(500, { message: "An error has occurred" }); } - redirect(302, "/"); + redirect(302, resolve("/notes")); }, ); @@ -87,6 +89,6 @@ export const logout = form( await auth.invalidateSession(authData.session.userId); auth.deleteSessionTokenCookie(cookies); - redirect(302, "/login"); + redirect(302, resolve("/login")); }, ); diff --git a/src/lib/remote/notes.remote.ts b/src/lib/remote/notes.remote.ts index dd20848..e93eaaf 100644 --- a/src/lib/remote/notes.remote.ts +++ b/src/lib/remote/notes.remote.ts @@ -11,17 +11,14 @@ import { reorderNotesSchema, updateNoteSchema, } from "./notes.schemas.ts"; -import { LoroDoc } from "loro-crdt"; -import { getEncryptedSnapshot } from "$lib/loro.ts"; export const getNotes = query(async (): Promise => { const { user } = requireLogin(); - const userNotes = await db.query.notes.findMany({ - where: { - ownerId: user.id, - }, - }); + const userNotes = await db + .select() + .from(notes) + .where(eq(notes.ownerId, user.id)); return userNotes.map( (n) => @@ -43,34 +40,26 @@ export const createNote = command( encryptedKey, parentId, isFolder, + encryptedSnapshot, }): Promise> => { const { user } = requireLogin(); try { const id = crypto.randomUUID(); - const loroSnapshot = await getEncryptedSnapshot( - new LoroDoc(), - encryptedKey, - ); - await db.insert(notes).values({ id, title, ownerId: user.id, encryptedKey, - loroSnapshot, + loroSnapshot: encryptedSnapshot, parentId, isFolder, createdAt: new Date(), updatedAt: new Date(), } satisfies typeof notes.$inferInsert); - const note = await db.query.notes.findFirst({ - where: { - id: id, - }, - }); + const [note] = await db.select().from(notes).where(eq(notes.id, id)); if (!note) throw new Error("Failed to find newly created note!"); @@ -90,11 +79,7 @@ export const deleteNote = command( try { // Verify ownership - const note = await db.query.notes.findFirst({ - where: { - id: noteId, - }, - }); + const [note] = await db.select().from(notes).where(eq(notes.id, noteId)); if (!note || note.ownerId !== user.id) error(404, "Not found"); @@ -118,11 +103,10 @@ export const updateNote = command( try { // Verify ownership - const existingNote = await db.query.notes.findFirst({ - where: { - id: noteId, - }, - }); + const [existingNote] = await db + .select() + .from(notes) + .where(eq(notes.id, noteId)); if (!existingNote || existingNote.ownerId !== user.id) { error(404, "Not found"); @@ -139,11 +123,10 @@ export const updateNote = command( }) .where(eq(notes.id, noteId)); - const updated = await db.query.notes.findFirst({ - where: { - id: noteId, - }, - }); + const [updated] = await db + .select() + .from(notes) + .where(eq(notes.id, noteId)); if (!updated) throw new Error("Failed to find newly created note!"); diff --git a/src/lib/remote/notes.schemas.ts b/src/lib/remote/notes.schemas.ts index 2ee1bee..f41d0e7 100644 --- a/src/lib/remote/notes.schemas.ts +++ b/src/lib/remote/notes.schemas.ts @@ -6,6 +6,7 @@ export const CreateNoteSchema = Schema.Struct({ parentId: Schema.String.pipe(Schema.NullOr), isFolder: Schema.Boolean, encryptedKey: Uint8ArrayFromSelfSchema, + encryptedSnapshot: Uint8ArrayFromSelfSchema, }); export const createNoteSchema = CreateNoteSchema.pipe(Schema.standardSchemaV1); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index fbbafbb..7a37e52 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -6,6 +6,7 @@ import { db } from "$lib/server/db"; import * as table from "$lib/server/db/schema"; import type { User } from "$lib/schema.ts"; import { getRequestEvent } from "$app/server"; +import { resolve } from "$app/paths"; const DAY_IN_MS = 1000 * 60 * 60 * 24; @@ -120,7 +121,7 @@ export function guardLogin(): SomeAuthData { } = getRequestEvent(); if (!user || !session) { - redirect(302, "/login"); + redirect(302, resolve("/login")); } return { user, session }; diff --git a/src/lib/server/db/columns.ts b/src/lib/server/db/columns.ts new file mode 100644 index 0000000..d58712a --- /dev/null +++ b/src/lib/server/db/columns.ts @@ -0,0 +1,44 @@ +import { customType } from "drizzle-orm/sqlite-core"; +import { Temporal } from "temporal-polyfill"; +import { SQL } from "drizzle-orm"; + +// https://github.com/drizzle-team/drizzle-orm/issues/4419#issuecomment-2885561863 +export const instant = customType<{ + data: Temporal.Instant; + driverData: number; +}>({ + dataType: () => { + return "timestamp"; + }, + fromDriver: (value) => { + return Temporal.Instant.fromEpochMilliseconds(value); + }, + toDriver: (value: Temporal.Instant | SQL) => { + if (value instanceof SQL) { + return value; + } + + return value.epochMilliseconds; + }, +}); + +export const uint8array = customType<{ + data: Uint8Array; + driverData: Buffer; +}>({ + dataType: () => { + return "blob"; + }, + fromDriver: (value) => { + // Buffer.buffer can be a shared ArrayBuffer larger than the actual data. + // Copy to a new Uint8Array with its own ArrayBuffer to avoid SharedArrayBuffer issues. + return Uint8Array.from(value); + }, + toDriver: (value: Uint8Array | SQL) => { + if (value instanceof SQL) { + return value; + } + + return Buffer.from(value); + }, +}); diff --git a/src/lib/server/db/relations.ts b/src/lib/server/db/relations.ts index 0d1b8c4..2ae3272 100644 --- a/src/lib/server/db/relations.ts +++ b/src/lib/server/db/relations.ts @@ -17,7 +17,6 @@ export const relations = defineRelations(schema, (r) => ({ from: r.notes.ownerId, to: r.users.id, }), - shares: r.many.noteShares(), parent: r.one.notes({ from: r.notes.parentId, to: r.notes.id, @@ -29,10 +28,4 @@ export const relations = defineRelations(schema, (r) => ({ to: r.notes.parentId, }), }, - noteShares: { - note: r.one.notes({ - from: r.noteShares.noteId, - to: r.notes.id, - }), - }, })); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index a1a8d3a..a5a9a23 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,18 +1,13 @@ -import { sqliteTable, text, integer, blob } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { instant, uint8array } from "./columns.ts"; export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull().unique(), passwordHash: text("password_hash").notNull(), - publicKey: blob("public_key", { mode: "buffer" }) - .$type>() - .notNull(), - privateKeyEncrypted: blob("private_key_encrypted", { mode: "buffer" }) - .$type>() - .notNull(), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), + publicKey: uint8array("public_key").notNull(), + privateKeyEncrypted: uint8array("private_key_encrypted").notNull(), + createdAt: instant("created_at").notNull(), }); export const sessions = sqliteTable("sessions", { @@ -29,12 +24,8 @@ export const notes = sqliteTable("notes", { ownerId: text("owner_id") .notNull() .references(() => users.id), - encryptedKey: blob("encrypted_key", { mode: "buffer" }) - .$type>() - .notNull(), - loroSnapshot: blob("loro_snapshot", { mode: "buffer" }) - .$type>() - .notNull(), + encryptedKey: uint8array("encrypted_key").notNull(), + loroSnapshot: uint8array("loro_snapshot").notNull(), parentId: text("parent_id"), isFolder: integer("is_folder", { mode: "boolean" }).notNull().default(false), order: integer("order").notNull().default(0), @@ -46,20 +37,6 @@ export const notes = sqliteTable("notes", { .$defaultFn(() => new Date()), }); -export const noteShares = sqliteTable("note_shares", { - id: text("id").primaryKey(), - noteId: text("note_id") - .notNull() - .references(() => notes.id), - sharedWithUser: text("shared_with_user").notNull(), - encryptedKey: text("encrypted_key").notNull(), - permissions: text("permissions").notNull().default("read"), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), -}); - export type User = typeof users.$inferSelect; export type Session = typeof sessions.$inferSelect; export type Note = typeof notes.$inferSelect; -export type NoteShare = typeof noteShares.$inferSelect; diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts new file mode 100644 index 0000000..d78932b --- /dev/null +++ b/src/lib/utils/time.ts @@ -0,0 +1,28 @@ +import { Temporal } from "temporal-polyfill"; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + +export function formatRelativeTime(then: Temporal.Instant): string { + const now = Temporal.Now.zonedDateTimeISO(); + const thenZoned = then.toZonedDateTimeISO(now.timeZoneId); + + // If before today's midnight, show days ago using Intl + const daysDiff = thenZoned.toPlainDate().until(now.toPlainDate()).days; + if (daysDiff >= 1) { + return rtf.format(-daysDiff, "day"); + } + + // Otherwise show hours/minutes/seconds using Intl + const duration = now.toInstant().since(then, { + largestUnit: "hour", + smallestUnit: "second", + }); + + if (duration.hours >= 1) { + return rtf.format(-duration.hours, "hour"); + } + if (duration.minutes >= 1) { + return rtf.format(-duration.minutes, "minute"); + } + return rtf.format(-duration.seconds, "second"); +} diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte index 6e28e32..9d17de6 100644 --- a/src/routes/(auth)/signup/+page.svelte +++ b/src/routes/(auth)/signup/+page.svelte @@ -79,7 +79,7 @@ submitter.form!.requestSubmit(submitter); }} > - {signup.pending !== 0 ? "Logging in..." : "Log In"} + {signup.pending !== 0 ? "Signing up..." : "Sign up"} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index f31faa5..9751ec1 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,7 @@ import type { User } from "$lib/schema.ts"; import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema.ts"; +import { eq } from "drizzle-orm"; export interface Data { user: User | undefined; @@ -13,20 +15,15 @@ export const load = async ({ locals }): Promise => { } // Get user with private key from database - const user = await db.query.users.findFirst({ - where: { - id: localUser.id, - }, - }); + const [user] = await db + .select({ + id: table.users.id, + username: table.users.username, + publicKey: table.users.publicKey, + privateKeyEncrypted: table.users.privateKeyEncrypted, + }) + .from(table.users) + .where(eq(table.users.id, localUser.id)); - return { - user: user - ? { - id: user.id, - username: user.username, - publicKey: user.publicKey, - privateKeyEncrypted: user.privateKeyEncrypted, - } - : undefined, - }; + return { user }; }; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index b18dfb2..2da0765 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,61 +1,8 @@ -import { db } from "$lib/server/db"; -import { notes } from "$lib/server/db/schema"; -import { and, count, eq } from "drizzle-orm"; +import { resolve } from "$app/paths"; +import { redirect } from "@sveltejs/kit"; -// TODO: Make this a remote function instead. -interface Data { - totalNotes: number; - randomNote: - | { - id: string; - title: string; - updatedAt: Date; - } - | null - | undefined; -} - -export const load = async ({ locals }): Promise => { - const user = locals.user; - - if (!user) { - return { - totalNotes: 0, - randomNote: null, - }; - } - - // Get total notes count (excluding folders) - const totalNotesResult = await db - .select({ count: count() }) - .from(notes) - .where(and(eq(notes.ownerId, user.id), eq(notes.isFolder, false))); - - const totalNotes = totalNotesResult[0]?.count ?? 0; - - // Get a random note (excluding folders) - let randomNote = null; - if (totalNotes > 0) { - const userNotes = await db.query.notes.findMany({ - where: { - ownerId: user.id, - isFolder: false, - }, - columns: { - id: true, - title: true, - updatedAt: true, - }, - }); - - if (userNotes.length > 0) { - const randomIndex = Math.floor(Math.random() * userNotes.length); - randomNote = userNotes[randomIndex]; - } +export const load = (event): void => { + if (event.locals.user) { + redirect(302, resolve("/notes/")); } - - return { - totalNotes, - randomNote, - }; }; diff --git a/src/routes/notes/+layout.svelte b/src/routes/notes/+layout.svelte new file mode 100644 index 0000000..d8c3df2 --- /dev/null +++ b/src/routes/notes/+layout.svelte @@ -0,0 +1,77 @@ + + + + +
+ {#if data.user} + + {/if} + + {@render children()} +
diff --git a/src/routes/notes/+page.server.ts b/src/routes/notes/+page.server.ts new file mode 100644 index 0000000..b18dfb2 --- /dev/null +++ b/src/routes/notes/+page.server.ts @@ -0,0 +1,61 @@ +import { db } from "$lib/server/db"; +import { notes } from "$lib/server/db/schema"; +import { and, count, eq } from "drizzle-orm"; + +// TODO: Make this a remote function instead. +interface Data { + totalNotes: number; + randomNote: + | { + id: string; + title: string; + updatedAt: Date; + } + | null + | undefined; +} + +export const load = async ({ locals }): Promise => { + const user = locals.user; + + if (!user) { + return { + totalNotes: 0, + randomNote: null, + }; + } + + // Get total notes count (excluding folders) + const totalNotesResult = await db + .select({ count: count() }) + .from(notes) + .where(and(eq(notes.ownerId, user.id), eq(notes.isFolder, false))); + + const totalNotes = totalNotesResult[0]?.count ?? 0; + + // Get a random note (excluding folders) + let randomNote = null; + if (totalNotes > 0) { + const userNotes = await db.query.notes.findMany({ + where: { + ownerId: user.id, + isFolder: false, + }, + columns: { + id: true, + title: true, + updatedAt: true, + }, + }); + + if (userNotes.length > 0) { + const randomIndex = Math.floor(Math.random() * userNotes.length); + randomNote = userNotes[randomIndex]; + } + } + + return { + totalNotes, + randomNote, + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/notes/+page.svelte similarity index 64% rename from src/routes/+page.svelte rename to src/routes/notes/+page.svelte index 7d2c9b0..7aa4421 100644 --- a/src/routes/+page.svelte +++ b/src/routes/notes/+page.svelte @@ -1,14 +1,19 @@ -
+

Dashboard

-
+
@@ -31,9 +36,11 @@ {data.randomNote.title}

- Last updated: {new Date( - data.randomNote.updatedAt, - ).toLocaleDateString()} + Last updated: {formatRelativeTime( + Temporal.Instant.fromEpochMilliseconds( + data.randomNote.updatedAt.getTime(), + ), + )}

@@ -57,9 +64,16 @@

- Create New Note + {#if sidebar.isCollapsed} + + {:else} +

+ Use the sidebar to create your first note +

+ {/if}
diff --git a/src/routes/notes/[id]/+layout.svelte b/src/routes/notes/[id]/+layout.svelte deleted file mode 100644 index c30ffe0..0000000 --- a/src/routes/notes/[id]/+layout.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- {#if data.user} - - {/if} - - {@render children()} -
diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index d8d3897..effe922 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -5,7 +5,7 @@ import { LoroNoteManager } from "$lib/loro.ts"; import { getNotes, updateNote } from "$lib/remote/notes.remote.ts"; import { unawaited } from "$lib/unawaited.ts"; - import { decryptKey } from "$lib/crypto"; + import { decryptKey } from "$lib/crypto.ts"; import { FilePlus, Folder } from "@lucide/svelte"; const { data } = $props(); @@ -38,6 +38,11 @@ key = await decryptKey(note.encryptedKey, userPrivateKey); } catch (e) { console.error("Failed to decrypt key:", e); + console.debug("Debug info:", { + encryptedKeyLen: note.encryptedKey.byteLength, + userPrivateKeyLen: userPrivateKey?.byteLength, + noteId: note.id, + }); } }