diff --git a/.gitignore b/.gitignore index 3928090..9ff70c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +test-results node_modules # Output @@ -26,3 +27,8 @@ vite.config.ts.timestamp-* *.db .vscode/settings.json + +*storybook.log +storybook-static + +coverage/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..04506c8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "svelte": { + "type": "http", + "url": "https://mcp.svelte.dev/mcp" + } + } +} diff --git a/.prettierrc b/.prettierrc index 9320ac2..21f986b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -8,5 +8,5 @@ } } ], - "tailwindStylesheet": "src/routes/layout.css" + "tailwindStylesheet": "./src/routes/layout.css" } diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..54a0c23 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,14 @@ +import type { StorybookConfig } from "@storybook/sveltekit"; + +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"], + addons: [ + "@storybook/addon-svelte-csf", + "@chromatic-com/storybook", + "@storybook/addon-vitest", + "@storybook/addon-a11y", + "@storybook/addon-docs", + ], + framework: "@storybook/sveltekit", +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..f9ea2a1 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from "@storybook/sveltekit"; + +export default { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + test: "error", + }, + }, +} satisfies Preview; diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 0000000..8c208ef --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from "@storybook/sveltekit"; +import * as projectAnnotations from "./preview"; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 02bbfa2..e6345a7 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,57 +1,7 @@ { "servers": { "svelte": { - "type": "http", "url": "https://mcp.svelte.dev/mcp" - }, - "ESLint": { - "type": "stdio", - "command": "pnpm", - "args": [ - "dlx", - "--package=jiti", - "--package=@eslint/mcp@latest", - "-s", - "mcp" - ] - }, - "io.github.upstash/context7": { - "type": "stdio", - "command": "pnpm", - "args": [ - "dlx", - "-s", - "@upstash/context7-mcp@latest", - "--api-key", - "${input:CONTEXT7_API_KEY}" - ] - }, - "io.github.ChromeDevTools/chrome-devtools-mcp": { - "type": "stdio", - "command": "pnpm", - "args": ["dlx", "-s", "chrome-devtools-mcp@0.12.0"] - }, - "socket-mcp": { - "type": "stdio", - "command": "pnpm", - "args": ["dlx", "-s", "@socketsecurity/mcp@latest"], - "env": { - "SOCKET_API_KEY": "${input:socket_api_key}" - } } - }, - "inputs": [ - { - "id": "CONTEXT7_API_KEY", - "type": "promptString", - "description": "API key for authentication", - "password": true - }, - { - "type": "promptString", - "id": "socket_api_key", - "description": "Socket API Key", - "password": true - } - ] + } } diff --git a/.vscode/settings.example.json b/.vscode/settings.example.json index 7bf29b2..3ab3869 100644 --- a/.vscode/settings.example.json +++ b/.vscode/settings.example.json @@ -2,7 +2,7 @@ "files.associations": { "*.css": "tailwindcss" }, - "svelte.enable-ts-plugin": true, + "svelte.enable-ts-plugin": false, "svelte.ask-to-enable-ts-plugin": false, "svelte.language-server.runtime": "node", "eslint.validate": ["javascript", "typescript", "svelte"], diff --git a/AGENTS.md b/AGENTS.md index d1eeb06..085aa09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,214 +1,25 @@ -# Agent Guidelines +# Dear Agent, -## Planning & Workflow +You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: -> [!IMPORTANT] -> **ALWAYS plan before implementing.** -> -> 1. Break down tasks into small steps. -> 2. Use `manage_todo_list`. -> 3. Identify files and dependencies. +## Available MCP Tools: -### Do's +### 1. list-sections -- **Read code first** (`read_file`). -- **Make small, incremental changes**. -- **Test your changes**. -- **Use Svelte MCP** (`svelte-autofixer` is mandatory). -- **Follow DaisyUI** (semantic classes like `btn-primary`). -- **Validate with ESLint**. -- **Check DB schema** (`src/lib/server/db/schema.ts`). -- **Use Drizzle ORM syntax** (always prefer `db.select()` over `db.query` or raw `sql` templates). -- **Mark todos complete** immediately. +Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. +When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. -### Don'ts +### 2. get-documentation -- **No raw Tailwind colors**. -- **No large sweeping changes**. -- **Don't skip `svelte-autofixer`**. -- **Don't ignore TS/ESLint errors**. -- **No emojis**. +Retrieves full documentation content for specific sections. Accepts single or multiple sections. +After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. -### Definition of Done +### 3. svelte-autofixer -- Code implemented & tested. -- Diff is small and focused. -- `svelte-autofixer` passed. -- ESLint passed. -- Types correct. -- DaisyUI conventions followed. -- Todos completed. +Analyzes Svelte code and returns issues and suggestions. +You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. -## Git Hygiene +### 4. playground-link -- **Never Commit**: Generated code, dependencies, secrets, IDE settings, OS files, logs. -- **Safe to Commit**: Source, config, docs, tests, migrations. -- **Workflow**: Review diff -> Stage specific files -> Commit with conventional commit message. - -## MCP Servers - -### 1. Svelte (`mcp_svelte_*`) - -- `list-sections`: **First step** to find docs. -- `get-documentation`: Fetch relevant sections. -- `svelte-autofixer`: **Mandatory** before finalizing components. -- `playground-link`: Offer only after user confirmation. - -### 2. Context7 (`mcp_context7_*`) - -- `resolve-library-id` -> `get-library-docs`: For external library docs. -- Use proactively for new dependencies. - -### 3. ESLint (`mcp_eslint_*`) - -- `lint-files`: Validate changes. - -### 4. Socket (`mcp__extension_so_depscore`) - -- `depscore`: Check new dependencies. - -## Project Structure - -**Stack**: SvelteKit (Svelte 5), SQLite + Drizzle, Lucia Auth, DaisyUI, CodeMirror 6, Loro CRDT. - -### Key Directories - -```text -src/ -├── lib/ -│ ├── assets/ # Static assets -│ ├── components/ # Svelte components -│ ├── editor/ # Editor utilities -│ ├── remote/ # Remote data fetching -│ ├── server/ # Server-only code (Auth, DB, Real-time) -│ ├── types/ # TypeScript types -│ ├── utils/ # Shared utilities -│ ├── crypto.ts # Crypto utils -│ ├── loro.ts # Loro CRDT -│ ├── schema.ts # Shared Effect Schema -│ └── unawaited.ts # Unawaited promise handler -├── routes/ -│ ├── (auth)/ # Login/Signup -│ ├── api/ # API endpoints -│ ├── notes/ # Note pages -│ ├── +layout.svelte # Root layout -│ ├── +page.svelte # Home page -│ └── layout.css # Global styles -└── hooks.server.ts # Server hooks -``` - -### Important Files - -- `src/lib/server/db/schema.ts`: DB Schema (Users, Sessions, Notes). -- `src/lib/server/auth.ts`: Lucia-inspired Auth config. -- `src/lib/server/real-time.ts`: Loro CRDT sync. -- `src/lib/components/codemirror/`: Editor components. - -## Commands - -- **Type Check**: `pnpm check` -- **Format**: `pnpm prettier --write path/to/file.ts` -- **Migrations**: `pnpm drizzle-kit generate` -> `pnpm drizzle-kit migrate` -- **Lint**: Use ESLint MCP. - -## DaisyUI Styling - -Use semantic classes. Avoid raw Tailwind colors. - -### Correct - -```svelte - -
...
-Error -``` - -### Incorrect - -```svelte - -
...
-``` - -## Svelte 5 Patterns - -### Script Order - -1. Imports -2. Props (`$props()`) -3. Functions/Promises -4. Effects/State (`$effect`, `$state`) -5. Derived Async (`$derived(await query)`) - -### Context - -Use `createContext` from `svelte`. - -```typescript -import { createContext } from "svelte"; -export const [getLinkContext, setLinkContext] = createContext(); -``` - -### Remote Functions - -Wrap in `$derived` for reactivity. - -```svelte - -``` - -#### Optimistic Updates - -Use `.updates()` with `.withOverride()`. - -```typescript -import { getPosts, createPost } from "$lib/remote/posts.remote"; - -async function handleSubmit() { - const newPost = { id: "temp", title: "New Post" }; - await createPost(newPost).updates( - getPosts().withOverride((posts) => [newPost, ...posts]), - ); -} -``` - -### Shared State & SSR - -> [!WARNING] -> **NEVER** use global shared stores (exported `writable` or `$state` in module scope). - -In SSR, module state is shared across requests. -**Instead:** - -- Use `createContext` for component-scoped state. -- Pass data via `props`. - -### Optimistic UI - -Use `$derived` overrides or `$state.eager`. - -```svelte - -``` - -### Legacy Patterns (Avoid) - -- **No `load` functions**: Use Remote Functions. -- **No `{#await}`**: Use top-level `await` or ``s in ` - -
- {name[0]?.toUpperCase()} -
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte deleted file mode 100644 index 188f96c..0000000 --- a/src/lib/components/Sidebar.svelte +++ /dev/null @@ -1,376 +0,0 @@ - - - - - - - -{#if contextMenu} - {@const clickedId = contextMenu.noteId} -
- {#if contextMenu.isFolder} - - {/if} - - - - -
-{/if} - - - (renamingId = null)} -> - - - diff --git a/src/lib/components/TreeItem.svelte b/src/lib/components/TreeItem.svelte deleted file mode 100644 index 059a1d9..0000000 --- a/src/lib/components/TreeItem.svelte +++ /dev/null @@ -1,296 +0,0 @@ - - -
- - {#if closestEdge === "top"} -
- {/if} - {#if closestEdge === "bottom"} -
- {/if} - - -
- - {#if item.isFolder} - {@const isExpanded = expandedFolders.has(item.id)} - -
- - - - {#if isExpanded} -
- {#each item.children as child, idx (child.id)} - { - const children = item.children; - const itemToMove = notesList.find((n) => n.id === sourceId); - - if (!itemToMove) return; - - const updates = children - .filter((c) => c.id !== sourceId) - .toSpliced(targetIndex, 0, { ...itemToMove, children: [] }) - .map((c, i) => ({ id: c.id, order: i })); - await reorderNotes(updates).updates( - // TODO: add optimistic update. - getNotes(), - ); - }} - /> - {/each} - {#if item.children.length === 0} -
- Empty folder -
- {/if} -
- {/if} -
- {:else} - - { - handleContextMenu(e, item.id, false); - }} - draggable="false" - > - - {item.title || "Untitled"} - - {/if} -
diff --git a/src/lib/components/codemirror/Codemirror.svelte b/src/lib/components/codemirror/Codemirror.svelte deleted file mode 100644 index 5a19106..0000000 --- a/src/lib/components/codemirror/Codemirror.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
diff --git a/src/lib/components/codemirror/Editor.svelte b/src/lib/components/codemirror/Editor.svelte deleted file mode 100644 index f3a0714..0000000 --- a/src/lib/components/codemirror/Editor.svelte +++ /dev/null @@ -1,205 +0,0 @@ - - -
- - - -
diff --git a/src/lib/components/codemirror/Editor.ts b/src/lib/components/codemirror/Editor.ts deleted file mode 100644 index b1e33b6..0000000 --- a/src/lib/components/codemirror/Editor.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { markdown } from "@codemirror/lang-markdown"; -import { languages } from "@codemirror/language-data"; -import { - EditorView, - keymap, - type Command, - type KeyBinding, -} from "@codemirror/view"; -import { GFM } from "@lezer/markdown"; -import { - prosemarkBasicSetup, - prosemarkBaseThemeSetup, - prosemarkMarkdownSyntaxExtensions, -} from "@prosemark/core"; -import { - pastePlainTextExtension, - pasteRichTextExtension, -} from "@prosemark/paste-rich-text"; -import { htmlBlockExtension } from "@prosemark/render-html"; -import { Url } from "@effect/platform"; -import { Either } from "effect"; - -/** - * Wrap current editor selection with markdown syntax. - */ -function wrapSelection( - view: EditorView, - before: string, - after: string = before, - selection: { from: number; to: number } = view.state.selection.main, -): void { - const { from, to } = selection; - const selectedText = view.state.doc.sliceString(from, to); - - if (selectedText.length === 0) { - // No selection - insert markdown syntax and position cursor in the middle - view.dispatch({ - changes: { from, to, insert: `${before}${after}` }, - selection: { anchor: from + before.length }, - }); - } else { - // Has selection - wrap it and position cursor after - view.dispatch({ - changes: { from, to, insert: `${before}${selectedText}${after}` }, - selection: { - anchor: from + before.length + selectedText.length + after.length, - }, - }); - } -} - -/** - * {@linkcode wrapSelection}, but it unwraps if already wrapped. - */ -function toggleWrapper( - view: EditorView, - before: string, - after: string = before, -): void { - const { from, to } = view.state.selection.main; - const doc = view.state.doc; - - // Check if wrapped - if (from >= before.length && to + after.length <= doc.length) { - const beforeRange = doc.sliceString(from - before.length, from); - const afterRange = doc.sliceString(to, to + after.length); - - if (beforeRange === before && afterRange === after) { - // Unwrap - view.dispatch({ - changes: [ - { from: from - before.length, to: from, insert: "" }, - { from: to, to: to + after.length, insert: "" }, - ], - selection: { - anchor: from - before.length, - head: to - before.length, - }, - }); - return; - } - } - - wrapSelection(view, before, after, { from, to }); -} - -/** - * Insert text at the start of the current line. - */ -function insertAtLineStart(view: EditorView, text: string): void { - const { from } = view.state.selection.main; - const line = view.state.doc.lineAt(from); - - view.dispatch({ - changes: { from: line.from, to: line.from, insert: text }, - selection: { anchor: line.from + text.length }, - }); -} - -function commandToKeyRun(command: (target: EditorView) => void): Command { - return (view: EditorView) => { - command(view); - return true; - }; -} - -export const boldCommand = (view: EditorView): void => { - toggleWrapper(view, "**"); -}; - -export const italicCommand = (view: EditorView): void => { - toggleWrapper(view, "*"); -}; - -export const codeCommand = (view: EditorView): void => { - toggleWrapper(view, "`"); -}; - -export const strikethroughCommand = (view: EditorView): void => { - toggleWrapper(view, "~~"); -}; - -export const linkCommand = (view: EditorView): void => { - const { from, to } = view.state.selection.main; - const selectedText = view.state.doc.sliceString(from, to); - - Url.fromString(selectedText).pipe( - Either.match({ - onLeft: () => { - wrapSelection(view, "[", "](url)", { from, to }); - }, - onRight: () => { - wrapSelection(view, "[title](", ")", { from, to }); - }, - }), - ); -}; - -function headingCommandFactory(count: number): (view: EditorView) => void { - return (view: EditorView) => { - const { from } = view.state.selection.main; - const line = view.state.doc.lineAt(from); - - const match = /^(#{1,6})\s/.exec(line.text); - - if (match) { - const currentCount = match[1]?.length; - const end = line.from + match[0].length; - - if (currentCount === count) { - // Remove heading - view.dispatch({ - changes: { from: line.from, to: end, insert: "" }, - }); - } else { - // Change heading level - view.dispatch({ - changes: { - from: line.from, - to: end, - insert: "#".repeat(count) + " ", - }, - }); - } - } else { - insertAtLineStart(view, "#".repeat(count) + " "); - } - }; -} - -export const heading1Command = headingCommandFactory(1); -export const heading2Command = headingCommandFactory(2); -export const heading3Command = headingCommandFactory(3); - -export const bulletListCommand = (view: EditorView): void => { - const { from } = view.state.selection.main; - const line = view.state.doc.lineAt(from); - - const match = /^-\s/.exec(line.text); - - if (match) { - const end = line.from + 2; - - // Remove bullet - view.dispatch({ - changes: { from: line.from, to: end, insert: "" }, - }); - } else { - insertAtLineStart(view, "- "); - } -}; - -export const orderedListCommand = (view: EditorView): void => { - const { from } = view.state.selection.main; - const line = view.state.doc.lineAt(from); - - const match = /^\d+.\s/.exec(line.text); - - if (match) { - const end = line.from + 2; - - // Remove list - view.dispatch({ - changes: { from: line.from, to: end, insert: "" }, - }); - } else { - insertAtLineStart(view, "1. "); - } -}; - -/** Custom keyboard shortcuts for markdown formatting. */ -const markdownKeymap: KeyBinding[] = [ - { - // Bold - key: "Mod-b", - run: commandToKeyRun(boldCommand), - }, - { - // Italic - key: "Mod-i", - run: commandToKeyRun(italicCommand), - }, - { - // Link - key: "Mod-k", - run: commandToKeyRun(linkCommand), - }, - { - // Inline code - key: "Mod-e", - run: commandToKeyRun(codeCommand), - }, - { - // Strikethrough - key: "Mod-Shift-x", - run: commandToKeyRun(strikethroughCommand), - }, -]; - -export const coreExtensions = [ - // Adds support for the Markdown language - markdown({ - // adds support for standard syntax highlighting inside code fences - codeLanguages: languages, - extensions: [ - // GitHub Flavored Markdown (support for autolinks, strikethroughs) - GFM, - // additional parsing tags for existing markdown features, backslash escapes, emojis - ...prosemarkMarkdownSyntaxExtensions, - ], - }), - // Basic prosemark extensions - prosemarkBasicSetup(), - // Theme extensions - prosemarkBaseThemeSetup(), - htmlBlockExtension, - pasteRichTextExtension(), - pastePlainTextExtension(), - // Custom markdown keyboard shortcuts - keymap.of(markdownKeymap), -]; diff --git a/src/lib/components/codemirror/Toolbar.svelte b/src/lib/components/codemirror/Toolbar.svelte deleted file mode 100644 index ccf6c13..0000000 --- a/src/lib/components/codemirror/Toolbar.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -
- {#each tools as toolset, index (index)} -
- {#each toolset as tool (tool.title)} - {@const Icon = tool.icon} - - {/each} -
-
- {/each} -
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts deleted file mode 100644 index cc4c5f9..0000000 --- a/src/lib/crypto.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * WebCrypto utilities for E2EE - */ - -interface KeyPair { - publicKey: Uint8Array; - privateKey: Uint8Array; -} - -export async function generateUserKeys(): Promise { - const keyPair = await crypto.subtle.generateKey( - { - name: "RSA-OAEP", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["encrypt", "decrypt"], - ); - - const publicKeyData = await crypto.subtle.exportKey( - "spki", - keyPair.publicKey, - ); - const privateKeyData = await crypto.subtle.exportKey( - "pkcs8", - keyPair.privateKey, - ); - - return { - publicKey: new Uint8Array(publicKeyData), - - // TODO: Proper encryption - // For now, encode private key to base64 - // In production, use PBKDF2 to derive encryption key - privateKey: new Uint8Array(privateKeyData), - }; -} - -export async function generateNoteKey(): Promise> { - const key = await crypto.subtle.generateKey( - { - name: "AES-GCM", - length: 256, - }, - true, - ["encrypt", "decrypt"], - ); - - const keyData = await crypto.subtle.exportKey("raw", key); - return new Uint8Array(keyData); -} - -export async function encryptKeyForUser( - noteKey: Uint8Array, - recipientPublicKey: Uint8Array, -): Promise> { - const publicKey = await crypto.subtle.importKey( - "spki", - recipientPublicKey, - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["encrypt"], - ); - - const encrypted = await crypto.subtle.encrypt( - { - name: "RSA-OAEP", - }, - publicKey, - noteKey, - ); - - return new Uint8Array(encrypted); -} - -export async function decryptKey( - encryptedKey: Uint8Array, - privateKey: Uint8Array, -): Promise> { - const key = await crypto.subtle.importKey( - "pkcs8", - privateKey, - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["decrypt"], - ); - - const decrypted = await crypto.subtle.decrypt( - { - name: "RSA-OAEP", - }, - key, - encryptedKey, - ); - - return new Uint8Array(decrypted); -} - -export async function encryptData( - data: Uint8Array, - noteKey: Uint8Array, -): Promise> { - const key = await crypto.subtle.importKey( - "raw", - noteKey, - { - name: "AES-GCM", - }, - false, - ["encrypt"], - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - }, - key, - data, - ); - - // Prepend IV to encrypted data - const result = new Uint8Array(iv.length + encrypted.byteLength); - result.set(iv); - result.set(new Uint8Array(encrypted), iv.length); - - return result; -} - -export async function decryptData( - encrypted: Uint8Array, - noteKey: Uint8Array, -): Promise> { - const key = await crypto.subtle.importKey( - "raw", - noteKey, - { - name: "AES-GCM", - }, - false, - ["decrypt"], - ); - - // Extract IV from first 12 bytes - const iv = encrypted.slice(0, 12); - const data = encrypted.slice(12); - - const decrypted = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - }, - key, - data, - ); - - return new Uint8Array(decrypted); -} diff --git a/src/lib/editor/wikilinks.ts b/src/lib/editor/wikilinks.ts deleted file mode 100644 index 39fec53..0000000 --- a/src/lib/editor/wikilinks.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - Decoration, - ViewPlugin, - MatchDecorator, - EditorView, - WidgetType, - type ViewUpdate, -} from "@codemirror/view"; -import type { RangeSet } from "@codemirror/state"; -import { goto } from "$app/navigation"; -import { resolve } from "$app/paths"; -import type { NoteOrFolder } from "$lib/schema.ts"; - -class WikilinkWidget extends WidgetType { - title: string; - notesList: NoteOrFolder[]; - - constructor(title: string, notesList: NoteOrFolder[]) { - super(); - - this.title = title; - this.notesList = notesList; - } - - override toDOM(): HTMLAnchorElement { - const a = document.createElement("a"); - a.className = "cursor-pointer text-primary underline"; - a.textContent = `[[${this.title}]]`; - a.onclick = (e) => { - e.preventDefault(); - const targetNote = this.notesList.find((n) => n.title === this.title); - if (targetNote) { - goto(resolve("/notes/[id]", { id: targetNote.id })); - } else { - console.debug("Note not found:", this.title); - // Optional: Create note if not found? - } - }; - return a; - } - - override ignoreEvent(): boolean { - return false; - } -} - -export interface WikilinkPluginArgs { - notesList: NoteOrFolder[]; -} - -export const wikilinksExtension: ViewPlugin< - WikilinkPlugin, - WikilinkPluginArgs -> = ViewPlugin.define( - (v, { notesList }) => { - const wikilinkMatcher = new MatchDecorator({ - regexp: /\[\[([^\]]+)\]\]/g, - decoration: (match) => - Decoration.replace({ - widget: new WikilinkWidget( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- There is a capture group in the regex. - match[1]!, - notesList, - ), - }), - }); - - return new WikilinkPlugin(v, wikilinkMatcher); - }, - { - decorations: (instance) => instance.bookmarks, - provide: (plugin) => - EditorView.atomicRanges.of((view) => { - return view.plugin(plugin)?.bookmarks ?? Decoration.none; - }), - }, -); - -class WikilinkPlugin { - bookmarks: RangeSet; - wikilinkMatcher: MatchDecorator; - constructor(view: EditorView, wikilinkMatcher: MatchDecorator) { - this.bookmarks = wikilinkMatcher.createDeco(view); - this.wikilinkMatcher = wikilinkMatcher; - } - update(update: ViewUpdate): void { - this.bookmarks = this.wikilinkMatcher.updateDeco(update, this.bookmarks); - } -} diff --git a/src/lib/loro.ts b/src/lib/loro.ts deleted file mode 100644 index fead410..0000000 --- a/src/lib/loro.ts +++ /dev/null @@ -1,284 +0,0 @@ -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; -}>; - -export class LoroNoteManager { - #noteId: string; - #noteKey: Uint8Array; - #doc: Doc; - #text: LoroText; - #onUpdate: (snapshot: Uint8Array) => void | Promise; - #eventSource: EventSource | null = null; - #isSyncing = false; - - #outgoingHub: PubSub.PubSub>; - #persistenceHub: PubSub.PubSub; - - #persistenceFiber: Fiber.RuntimeFiber; - #outgoingFiber: Fiber.RuntimeFiber | null = null; - #incomingFiber: Fiber.RuntimeFiber | null = null; - - constructor( - noteId: string, - noteKey: Uint8Array, - onUpdate?: (snapshot: Uint8Array) => void | Promise, - ) { - this.#noteId = noteId; - this.#noteKey = noteKey; - this.#doc = new LoroDoc(); - this.#text = this.#doc.getText("content"); - this.#onUpdate = onUpdate ?? Function.constVoid; - - // Initialize frontiers - this.#lastFrontiers = this.#doc.frontiers(); - - // 1. Init Hubs - this.#outgoingHub = Effect.runSync( - PubSub.unbounded>(), - ); - this.#persistenceHub = Effect.runSync(PubSub.unbounded()); - - // 2. Persistence Loop (Debounced Snapshot) - const persistenceStream = Stream.fromPubSub(this.#persistenceHub).pipe( - Stream.debounce("500 millis"), - Stream.runForEach(() => - Effect.promise(async () => { - const snapshot = await getEncryptedSnapshot(this.#doc, this.#noteKey); - await this.#onUpdate(snapshot); - }), - ), - ); - this.#persistenceFiber = Effect.runFork(persistenceStream); - - // Subscribe to changes - this.#doc.subscribe((event) => { - // Notify content listeners - const content = this.getContent(); - this.#contentListeners.forEach((listener) => { - listener(content); - }); - - // Publish persistence signal - Effect.runSync(this.#persistenceHub.publish(null)); - - // Publish local ops for sync - if (event.by === "local") { - const frontiers = this.#doc.frontiers(); - try { - const update = this.#doc.export({ - mode: "shallow-snapshot", - frontiers: this.#lastFrontiers, - }) as Uint8Array; - this.#lastFrontiers = frontiers; - if (update.length > 0) { - Effect.runSync(this.#outgoingHub.publish(update)); - } - } catch (e) { - console.error("Error exporting update", e); - } - } - }); - } - - destroy(): void { - this.stopSync(); - Effect.runFork(Fiber.interrupt(this.#persistenceFiber)); - } - - #contentListeners: ((content: string) => void)[] = []; - - /** - * Subscribe to content changes - */ - subscribeToContent(listener: (content: string) => void): () => void { - this.#contentListeners.push(listener); - // Return unsubscribe function - return () => { - this.#contentListeners = this.#contentListeners.filter( - (l) => l !== listener, - ); - }; - } - - /** - * Initialize the manager with an encrypted snapshot - */ - async init(encryptedSnapshot?: Uint8Array): Promise { - if (encryptedSnapshot) { - await loadEncryptedSnapshot(encryptedSnapshot, this.#doc, this.#noteKey); - this.#lastFrontiers = this.#doc.frontiers(); - } - } - - #lastFrontiers: Frontiers; - - /** - * Start real-time sync - */ - startSync(): void { - if (this.#isSyncing) return; - this.#isSyncing = true; - - this.#eventSource = new EventSource(`/api/sync/${this.#noteId}`); - - // 3. Incoming Loop (Remote -> Loro) - const incomingStream = Stream.async>((emit) => { - if (this.#eventSource) { - this.#eventSource.onmessage = (event: MessageEvent): void => { - try { - const data = Schema.decodeSync(syncSchemaJson)(event.data); - - for (const update of data.updates) { - const updateBytes = Uint8Array.fromBase64(update); - unawaited(emit(Effect.succeed(Chunk.make(updateBytes)))); - } - } catch (error) { - console.error("Failed to process sync message:", error); - } - }; - - this.#eventSource.onerror = (error) => { - console.error("SSE connection error:", error); - this.#eventSource?.close(); - this.#isSyncing = false; - }; - } - }).pipe( - Stream.runForEach((update) => - Effect.sync(() => { - this.#doc.import(update); - }), - ), - ); - this.#incomingFiber = Effect.runFork(incomingStream); - - // 4. Outgoing Loop (Local -> Network) - const outgoingStream = Stream.fromPubSub(this.#outgoingHub).pipe( - Stream.groupedWithin(100, "500 millis"), - Stream.runForEach((chunk) => - Effect.promise(async () => { - if (Chunk.isEmpty(chunk)) return; - await this.#sendUpdates(Chunk.toReadonlyArray(chunk)); - }), - ), - ); - this.#outgoingFiber = Effect.runFork(outgoingStream); - } - - /** - * Stop real-time sync - */ - stopSync(): void { - if (this.#eventSource) { - this.#eventSource.close(); - this.#eventSource = null; - } - if (this.#incomingFiber) { - Effect.runFork(Fiber.interrupt(this.#incomingFiber)); - this.#incomingFiber = null; - } - if (this.#outgoingFiber) { - Effect.runFork(Fiber.interrupt(this.#outgoingFiber)); - this.#outgoingFiber = null; - } - this.#isSyncing = false; - } - - /** - * Send update to server - */ - async #sendUpdates(updates: readonly Uint8Array[]): Promise { - try { - await sync({ - noteId: this.#noteId, - updates: updates.map((u) => u.toBase64()), - }); - } catch (error) { - console.error("Failed to send update:", error); - } - } - - /** - * Get current text content - */ - getContent(): string { - return this.#text.toString(); - } - - /** - * Update text content using diffs - */ - updateContent(newContent: string): void { - const currentContent = this.#text.toString(); - if (currentContent === newContent) return; - - console.debug("[Loro] Updating content with diff..."); - - // Calculate diff - const diffs = diff(currentContent, newContent); - - let index = 0; - for (const [type, text] of diffs) { - switch (type) { - // DELETE - case -1: { - this.#text.delete(index, text.length); - break; - } - - // EQUAL - case 0: { - index += text.length; - break; - } - - // INSERT - case 1: { - this.#text.insert(index, text); - index += text.length; - break; - } - } - } - - this.#doc.commit(); - } -} - -/** - * Get encrypted snapshot for storage - */ -export async function getEncryptedSnapshot( - doc: Doc, - noteKey: Uint8Array, -): Promise> { - const snapshot = doc.export({ - mode: "snapshot", - }) as Uint8Array; - return await encryptData(snapshot, noteKey); -} - -/** - * Load from encrypted snapshot - */ -async function loadEncryptedSnapshot( - encryptedSnapshot: Uint8Array, - doc: Doc, - noteKey: Uint8Array, -): Promise { - try { - const decrypted = await decryptData(encryptedSnapshot, noteKey); - doc.import(decrypted); - } catch (error) { - console.error("Failed to load encrypted snapshot:", error); - throw error; - } -} diff --git a/src/lib/remote/accounts.remote.ts b/src/lib/remote/accounts.remote.ts deleted file mode 100644 index 53f07fb..0000000 --- a/src/lib/remote/accounts.remote.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { resolve } from "$app/paths"; -import { form, getRequestEvent } from "$app/server"; -import * as auth from "$lib/server/auth.ts"; -import { db } from "$lib/server/db/index.ts"; -import * as table from "$lib/server/db/schema.ts"; -import { hash, verify } from "@node-rs/argon2"; -import { fail, invalid, redirect } from "@sveltejs/kit"; -import { eq } from "drizzle-orm"; -import { Redacted, Schema } from "effect"; -import { loginSchema, signupSchema } from "./accounts.schema.ts"; - -export const login = form( - loginSchema, - async ({ username, _password: password }) => { - const { cookies } = getRequestEvent(); - - const results = await db - .select() - .from(table.users) - .where(eq(table.users.username, username)); - - const existingUser = results.at(0); - if (!existingUser) { - invalid("Incorrect username or password"); - } - - const validPassword = await verify( - existingUser.passwordHash, - password.pipe(Redacted.value), - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }, - ); - if (!validPassword) { - invalid("Incorrect username or password"); - } - - const sessionToken = auth.generateSessionToken(); - const session = await auth.createSession(sessionToken, existingUser.id); - auth.setSessionTokenCookie(cookies, sessionToken, session.expiresAt); - - return redirect(302, resolve("/")); - }, -); - -export const signup = form( - signupSchema, - async ({ username, _password: password, publicKey, privateKeyEncrypted }) => { - const { cookies } = getRequestEvent(); - - const id = crypto.randomUUID(); - const passwordHash = await hash(password.pipe(Redacted.value), { - // recommended minimum parameters - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); - - try { - await db.insert(table.users).values({ - id, - username, - passwordHash, - publicKey, - privateKeyEncrypted, - createdAt: new Date(), - } satisfies table.User); - - const sessionToken = auth.generateSessionToken(); - const session = await auth.createSession(sessionToken, id); - auth.setSessionTokenCookie(cookies, sessionToken, session.expiresAt); - } catch { - return fail(500, { message: "An error has occurred" }); - } - redirect(302, resolve("/")); - }, -); - -export const logout = form( - Schema.Struct({}).pipe(Schema.standardSchemaV1), - async () => { - const { cookies } = getRequestEvent(); - const authData = auth.guardLogin(); - await auth.invalidateSession(authData.session.userId); - auth.deleteSessionTokenCookie(cookies); - - redirect(302, resolve("/login")); - }, -); diff --git a/src/lib/remote/accounts.schema.ts b/src/lib/remote/accounts.schema.ts deleted file mode 100644 index 427032f..0000000 --- a/src/lib/remote/accounts.schema.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Uint8ArrayFromBase64Schema } from "$lib/schema.ts"; -import { Schema } from "effect"; - -const UsernameSchema = Schema.String.pipe( - Schema.pattern(/^[a-z0-9_-]+$/, { - message: () => "Invalid username (alphanumeric only)", - }), - Schema.length( - { min: 3, max: 31 }, - { - message: () => "Invalid username (min 3, max 31 characters)", - }, - ), - Schema.annotations({ - title: "Username", - description: "username", - identifier: "Username", - }), -); - -const PasswordSchema = Schema.String.pipe( - Schema.length( - { min: 6, max: 255 }, - { - message: () => ({ - message: "Invalid password (min 6, max 255 characters)", - override: true, - }), - }, - ), - Schema.brand("Password", { - title: "Password", - description: "password", - identifier: "Password", - }), - Schema.Redacted, -); - -const LoginSchema = Schema.Struct({ - username: UsernameSchema, - _password: PasswordSchema.annotations({ - title: "Password", - description: "account password", - }), -}); - -export const loginSchema = LoginSchema.pipe(Schema.standardSchemaV1); - -const SignupSchema = Schema.Struct({ - username: UsernameSchema, - _password: PasswordSchema.annotations({ - title: "Password", - description: "account password", - }), - publicKey: Uint8ArrayFromBase64Schema, - privateKeyEncrypted: Uint8ArrayFromBase64Schema, -}); - -export const signupSchema = SignupSchema.pipe(Schema.standardSchemaV1); diff --git a/src/lib/remote/notes.remote.ts b/src/lib/remote/notes.remote.ts deleted file mode 100644 index d3bdd5e..0000000 --- a/src/lib/remote/notes.remote.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { command, query } from "$app/server"; -import type { NoteOrFolder } from "$lib/schema.ts"; -import { requireLogin } from "$lib/server/auth.ts"; -import { db } from "$lib/server/db/index.ts"; -import { notes } from "$lib/server/db/schema.ts"; -import { error } from "@sveltejs/kit"; -import { and, eq } from "drizzle-orm"; -import { - createNoteSchema, - deleteNoteSchema, - 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 - .select() - .from(notes) - .where(eq(notes.ownerId, user.id)); - - return userNotes.map( - (n) => - ({ - ...n, - content: "", // Will be decrypted when selected - order: n.order, - createdAt: new Date(n.createdAt), - updatedAt: new Date(n.updatedAt), - }) satisfies NoteOrFolder, - ); -}); - -/** @todo Switch to form? */ -export const createNote = command( - createNoteSchema, - async ({ - title, - encryptedKey, - parentId, - isFolder, - }): 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, - parentId, - isFolder, - createdAt: new Date(), - updatedAt: new Date(), - } satisfies typeof notes.$inferInsert); - - const [note] = await db.select().from(notes).where(eq(notes.id, id)); - - if (!note) throw new Error("Failed to find newly created note!"); - - return note; - } catch (err) { - console.error("Create note error:", err); - return error(500, "Failed to create note"); - } - }, -); - -/** @todo Switch to form? */ -export const deleteNote = command( - deleteNoteSchema, - async (noteId): Promise => { - const { user } = requireLogin(); - - try { - // Verify ownership - const [note] = await db.select().from(notes).where(eq(notes.id, noteId)); - - if (!note || note.ownerId !== user.id) error(404, "Not found"); - - await db.delete(notes).where(eq(notes.id, noteId)); - } catch (err) { - console.error("Delete note error:", err); - error(500, "Failed to delete note"); - } - }, -); - -export const updateNote = command( - updateNoteSchema, - async ({ - noteId, - title, - loroSnapshot, - parentId, - }): Promise> => { - const { user } = requireLogin(); - - try { - // Verify ownership - const [existingNote] = await db - .select() - .from(notes) - .where(eq(notes.id, noteId)); - - if (!existingNote || existingNote.ownerId !== user.id) { - error(404, "Not found"); - } - - // Update note - await db - .update(notes) - .set({ - loroSnapshot: loroSnapshot ?? existingNote.loroSnapshot, - title: title ?? existingNote.title, - parentId: parentId ?? existingNote.parentId, - updatedAt: new Date(), - }) - .where(eq(notes.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!"); - - return updated; - } catch (err) { - console.error("[API] Update error:", err); - error(500, "Failed to update note"); - } - }, -); - -function isTuple(array: T[]): array is [T, ...T[]] { - return array.length > 0; -} - -export const reorderNotes = command( - reorderNotesSchema, - async (updates): Promise => { - const { user } = requireLogin(); - - try { - const updateStatements = updates.map(({ id, order: newOrder }) => - db - .update(notes) - .set({ order: newOrder, updatedAt: new Date() }) - .where(and(eq(notes.id, id), eq(notes.ownerId, user.id))), - ); - - // If no notes exist, do nothing. - if (!isTuple(updateStatements)) return; - - await db.batch(updateStatements); - } catch (err) { - console.error("Reorder error:", err); - error(500, "Failed to reorder notes"); - } - }, -); diff --git a/src/lib/remote/notes.schemas.ts b/src/lib/remote/notes.schemas.ts deleted file mode 100644 index 6a05082..0000000 --- a/src/lib/remote/notes.schemas.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Uint8ArrayFromSelfSchema } from "$lib/schema.ts"; -import { Schema } from "effect"; - -const CreateNoteSchema = Schema.Struct({ - title: Schema.String, - parentId: Schema.String.pipe(Schema.NullOr), - isFolder: Schema.Boolean, - encryptedKey: Uint8ArrayFromSelfSchema, -}); -export const createNoteSchema = CreateNoteSchema.pipe(Schema.standardSchemaV1); - -const DeleteNoteSchema = Schema.String; -export const deleteNoteSchema = DeleteNoteSchema.pipe(Schema.standardSchemaV1); - -const UpdateNoteSchema = Schema.Struct({ - noteId: Schema.String, - title: Schema.optional(Schema.String), - loroSnapshot: Schema.optional(Uint8ArrayFromSelfSchema), - parentId: Schema.optional(Schema.String.pipe(Schema.NullOr)), -}); - -export const updateNoteSchema = UpdateNoteSchema.pipe(Schema.standardSchemaV1); - -const ReorderNotesSchema = Schema.Struct({ - id: Schema.String, - order: Schema.Number, -}).pipe(Schema.Array); - -export const reorderNotesSchema = ReorderNotesSchema.pipe( - Schema.standardSchemaV1, -); - -const SyncSchema = Schema.Struct({ - noteId: Schema.String, - updates: Schema.Array(Schema.String), -}); -export const syncSchemaJson = Schema.parseJson(SyncSchema); -export const syncSchema = SyncSchema.pipe(Schema.standardSchemaV1); diff --git a/src/lib/remote/sync.remote.ts b/src/lib/remote/sync.remote.ts deleted file mode 100644 index de57a8e..0000000 --- a/src/lib/remote/sync.remote.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { command, getRequestEvent } from "$app/server"; -import { db } from "$lib/server/db/index.ts"; -import * as table from "$lib/server/db/schema.ts"; -import { broadcast } from "$lib/server/real-time.ts"; -import { error } from "@sveltejs/kit"; -import { eq } from "drizzle-orm"; -import { Schema } from "effect"; -import { syncSchema, syncSchemaJson } from "./notes.schemas.ts"; - -export const sync = command(syncSchema, async ({ noteId, updates }) => { - const { locals } = getRequestEvent(); - const user = locals.user; - - if (!user) error(401, "Unauthorized"); - - try { - // Verify access - const note = await db - .select() - .from(table.notes) - .where(eq(table.notes.id, noteId)) - .get(); - - if (!note || note.ownerId !== user.id) error(404, "Not found"); - - console.debug("Syncing", noteId); - - // Broadcast update to all connected clients - // The update is expected to be a base64 string of the binary update - broadcast(noteId, Schema.encodeSync(syncSchemaJson)({ noteId, updates })); - } catch (err) { - console.error("Sync update error:", err); - error(500, "Failed to process update"); - } -}); diff --git a/src/lib/schema.ts b/src/lib/schema.ts deleted file mode 100644 index d16fdef..0000000 --- a/src/lib/schema.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Schema } from "effect"; - -export const Uint8ArrayFromSelfSchema = - Schema.Uint8ArrayFromSelf as Schema.Schema< - Uint8Array, - Uint8Array - >; - -export const Uint8ArrayFromBase64Schema = - Schema.Uint8ArrayFromBase64 as Schema.Schema, string>; - -interface NoteBase { - id: string; - title: string; - content: string; - ownerId: string; - encryptedKey: Uint8Array; - parentId: string | null; - order: number; - createdAt: Date; - updatedAt: Date; -} - -export interface Note extends NoteBase { - loroSnapshot: Uint8Array; - isFolder: false; -} -export interface Folder extends NoteBase { - isFolder: true; -} -// TODO: Add in Drawing support via Excalidraw -export type NoteOrFolder = Note | Folder; - -export interface User { - id: string; - username: string; - publicKey: Uint8Array; - privateKeyEncrypted: Uint8Array; -} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 90e5692..ed74b22 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,12 +1,9 @@ -import { resolve } from "$app/paths"; -import { getRequestEvent } from "$app/server"; -import type { User } from "$lib/schema.ts"; -import { db } from "$lib/server/db"; -import * as table from "$lib/server/db/schema"; +import type { RequestEvent } from "@sveltejs/kit"; +import { eq } from "drizzle-orm"; import { sha256 } from "@oslojs/crypto/sha2"; import { encodeBase64url, encodeHexLowerCase } from "@oslojs/encoding"; -import { error, redirect, type Cookies } from "@sveltejs/kit"; -import { eq } from "drizzle-orm"; +import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema"; const DAY_IN_MS = 1000 * 60 * 60 * 24; @@ -24,16 +21,16 @@ export async function createSession( ): Promise { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const session: table.Session = { - token: sessionId, + id: sessionId, userId, expiresAt: new Date(Date.now() + DAY_IN_MS * 30), }; - await db.insert(table.sessions).values(session); + await db.insert(table.session).values(session); return session; } export interface Session { - token: string; + id: string; userId: string; expiresAt: Date; } @@ -48,6 +45,11 @@ interface SomeAuthData { user: User; } +export interface User { + id: string; + username: string; +} + export type AuthData = NoAuthData | SomeAuthData; export async function validateSessionToken(token: string): Promise { @@ -55,29 +57,21 @@ export async function validateSessionToken(token: string): Promise { const [result] = await db .select({ // Adjust user table here to tweak returned data - user: { - id: table.users.id, - username: table.users.username, - publicKey: table.users.publicKey, - privateKeyEncrypted: table.users.privateKeyEncrypted, - }, - session: table.sessions, + user: { id: table.user.id, username: table.user.username }, + session: table.session, }) - .from(table.sessions) - .innerJoin(table.users, eq(table.sessions.userId, table.users.id)) - .where(eq(table.sessions.token, sessionId)); + .from(table.session) + .innerJoin(table.user, eq(table.session.userId, table.user.id)) + .where(eq(table.session.id, sessionId)); - if (result === undefined) { + if (!result) { return { session: undefined, user: undefined }; } - const { session, user } = result; const sessionExpired = Date.now() >= session.expiresAt.getTime(); if (sessionExpired) { - await db - .delete(table.sessions) - .where(eq(table.sessions.token, session.token)); + await db.delete(table.session).where(eq(table.session.id, session.id)); return { session: undefined, user: undefined }; } @@ -86,53 +80,31 @@ export async function validateSessionToken(token: string): Promise { if (renewSession) { session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); await db - .update(table.sessions) + .update(table.session) .set({ expiresAt: session.expiresAt }) - .where(eq(table.sessions.token, session.token)); + .where(eq(table.session.id, session.id)); } return { session, user }; } export async function invalidateSession(sessionId: string): Promise { - await db.delete(table.sessions).where(eq(table.sessions.token, sessionId)); + await db.delete(table.session).where(eq(table.session.id, sessionId)); } export function setSessionTokenCookie( - cookies: Cookies, + event: RequestEvent, token: string, expiresAt: Date, ): void { - cookies.set(sessionCookieName, token, { + event.cookies.set(sessionCookieName, token, { expires: expiresAt, path: "/", }); } -export function deleteSessionTokenCookie(cookies: Cookies): void { - cookies.delete(sessionCookieName, { +export function deleteSessionTokenCookie(event: RequestEvent): void { + event.cookies.delete(sessionCookieName, { path: "/", }); } - -export function guardLogin(): SomeAuthData { - const { - locals: { user, session }, - } = getRequestEvent(); - - if (!user || !session) { - redirect(302, resolve("/login")); - } - - return { user, session }; -} - -export function requireLogin(): SomeAuthData { - const { - locals: { user, session }, - } = getRequestEvent(); - - if (!user || !session) error(401, "Unauthorized"); - - return { user, session }; -} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index f3027ef..7da0c6d 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,8 +1,7 @@ -import { env } from "$env/dynamic/private"; import { drizzle } from "drizzle-orm/libsql"; -import * as schema from "./schema.ts"; -import { relations } from "./relations.ts"; +import * as schema from "./schema"; +import { env } from "$env/dynamic/private"; -if (!env["DATABASE_URL"]) throw new Error("DATABASE_URL is not set"); +if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); -export const db = drizzle(env["DATABASE_URL"], { schema, relations }); +export const db = drizzle(env["DATABASE_URL"], { schema }); diff --git a/src/lib/server/db/relations.ts b/src/lib/server/db/relations.ts deleted file mode 100644 index 2ae3272..0000000 --- a/src/lib/server/db/relations.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineRelations } from "drizzle-orm"; -import * as schema from "./schema.ts"; - -export const relations = defineRelations(schema, (r) => ({ - users: { - sessions: r.many.sessions(), - notes: r.many.notes(), - }, - sessions: { - user: r.one.users({ - from: r.sessions.userId, - to: r.users.id, - }), - }, - notes: { - owner: r.one.users({ - from: r.notes.ownerId, - to: r.users.id, - }), - parent: r.one.notes({ - from: r.notes.parentId, - to: r.notes.id, - alias: "parent", - }), - children: r.many.notes({ - alias: "children", - from: r.notes.id, - to: r.notes.parentId, - }), - }, -})); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index c761eaf..1da2e67 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,51 +1,20 @@ -import { sqliteTable, text, integer, blob } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const users = sqliteTable("users", { +export const user = sqliteTable("user", { id: text("id").primaryKey(), + age: integer("age"), 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()), }); -export const sessions = sqliteTable("sessions", { - token: text("token").primaryKey(), +export const session = sqliteTable("session", { + id: text("id").primaryKey(), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => user.id), expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), }); -export const notes = sqliteTable("notes", { - id: text("id").primaryKey(), - title: text("title").notNull(), - ownerId: text("owner_id") - .notNull() - .references(() => users.id), - encryptedKey: blob("encrypted_key", { mode: "buffer" }) - .$type>() - .notNull(), - loroSnapshot: blob("loro_snapshot", { mode: "buffer" }) - .$type>() - .notNull(), - parentId: text("parent_id"), - isFolder: integer("is_folder", { mode: "boolean" }).notNull().default(false), - order: integer("order").notNull().default(0), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), -}); +export type Session = typeof session.$inferSelect; -export type User = typeof users.$inferSelect; -export type Session = typeof sessions.$inferSelect; -export type Note = typeof notes.$inferSelect; +export type User = typeof user.$inferSelect; diff --git a/src/lib/server/real-time.ts b/src/lib/server/real-time.ts deleted file mode 100644 index be17b60..0000000 --- a/src/lib/server/real-time.ts +++ /dev/null @@ -1,60 +0,0 @@ -// In-memory map of noteId to set of controller objects for SSE -// Using ReadableStreamDefaultController for SvelteKit's custom stream response -const clients = new Map>(); - -export function addClient( - noteId: string, - controller: ReadableStreamDefaultController, -): void { - if (!clients.has(noteId)) { - clients.set(noteId, new Set()); - } - clients.get(noteId)?.add(controller); - - console.debug( - `Client added to note ${noteId}. Total clients: ${(clients.get(noteId)?.size ?? 0).toFixed()}`, - ); -} - -export function removeClient( - noteId: string, - controller: ReadableStreamDefaultController, -): void { - const set = clients.get(noteId); - if (set) { - set.delete(controller); - if (set.size === 0) { - clients.delete(noteId); - } - console.debug( - `Client removed from note ${noteId}. Remaining: ${set.size.toFixed()}`, - ); - } -} - -export function broadcast( - noteId: string, - data: string, - senderController?: ReadableStreamDefaultController, -): void { - const set = clients.get(noteId); - if (!set) return; - - const payload = `data: ${data}\n\n`; - const encoder = new TextEncoder(); - const bytes = encoder.encode(payload); - - for (const controller of set) { - // Don't send back to sender if specified (though usually we want to confirm receipt or just rely on local application) - // For Loro, we usually apply local updates immediately, so we might skip sending back to sender. - // However, the sender is identified by the connection, so we can filter. - if (senderController && controller === senderController) continue; - - try { - controller.enqueue(bytes); - } catch (e) { - console.error(`Failed to send to client for note ${noteId}`, e); - removeClient(noteId, controller); - } - } -} diff --git a/src/lib/types/uint8array.ts b/src/lib/types/uint8array.ts deleted file mode 100644 index 98302fb..0000000 --- a/src/lib/types/uint8array.ts +++ /dev/null @@ -1,80 +0,0 @@ -declare global { - interface Uint8Array { - /** - * Converts the `Uint8Array` to a base64-encoded string. - * @param options If provided, sets the alphabet and padding behavior used. - * @returns A base64-encoded string. - */ - toBase64(options?: { - alphabet?: "base64" | "base64url" | undefined; - omitPadding?: boolean | undefined; - }): string; - - /** - * Sets the `Uint8Array` from a base64-encoded string. - * @param string The base64-encoded string. - * @param options If provided, specifies the alphabet and handling of the last chunk. - * @returns An object containing the number of bytes read and written. - * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last - * chunk is inconsistent with the `lastChunkHandling` option. - */ - setFromBase64( - string: string, - options?: { - alphabet?: "base64" | "base64url" | undefined; - lastChunkHandling?: - | "loose" - | "strict" - | "stop-before-partial" - | undefined; - }, - ): { - read: number; - written: number; - }; - - /** - * Converts the `Uint8Array` to a base16-encoded string. - * @returns A base16-encoded string. - */ - toHex(): string; - - /** - * Sets the `Uint8Array` from a base16-encoded string. - * @param string The base16-encoded string. - * @returns An object containing the number of bytes read and written. - */ - setFromHex(string: string): { - read: number; - written: number; - }; - } - - interface Uint8ArrayConstructor { - /** - * Creates a new `Uint8Array` from a base64-encoded string. - * @param string The base64-encoded string. - * @param options If provided, specifies the alphabet and handling of the last chunk. - * @returns A new `Uint8Array` instance. - * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last - * chunk is inconsistent with the `lastChunkHandling` option. - */ - fromBase64( - string: string, - options?: { - alphabet?: "base64" | "base64url" | undefined; - lastChunkHandling?: - | "loose" - | "strict" - | "stop-before-partial" - | undefined; - }, - ): Uint8Array; - - /** - * Creates a new `Uint8Array` from a base16-encoded string. - * @returns A new `Uint8Array` instance. - */ - fromHex(string: string): Uint8Array; - } -} diff --git a/src/lib/unawaited.ts b/src/lib/unawaited.ts deleted file mode 100644 index d0d13f7..0000000 --- a/src/lib/unawaited.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function unawaited(promise: Promise): void { - promise.catch((err: unknown) => { - console.error(err); - }); -} diff --git a/src/lib/utils/tree.ts b/src/lib/utils/tree.ts deleted file mode 100644 index 9d72ed2..0000000 --- a/src/lib/utils/tree.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Order } from "effect"; -import type { NoteOrFolder } from "$lib/schema"; - -export type TreeNode = NoteOrFolder & { children: TreeNode[] }; - -const byOrder = Order.mapInput( - Order.number, - (node) => node.order, -); - -/** Recursive function to sort children at all levels. */ -function treeToSorted(tree: readonly TreeNode[]): TreeNode[] { - return tree - .toSorted(byOrder) - .map((node) => - node.isFolder ? { ...node, children: treeToSorted(node.children) } : node, - ); -} - -export function buildNotesTree(notesList: NoteOrFolder[]): TreeNode[] { - const map = new Map(); - const roots: TreeNode[] = []; - - // First pass: create map entries - for (const note of notesList) { - map.set(note.id, { ...note, children: [] }); - } - - // Second pass: build tree - for (const note of notesList) { - const current = map.get(note.id); - - if (current === undefined) continue; - - if (note.parentId) { - const parent = map.get(note.parentId); - if (parent) { - parent.children.push(current); - } else { - // Parent not found (maybe deleted?), treat as root or orphan - roots.push(current); - } - } else { - roots.push(current); - } - } - - return treeToSorted(roots); -} - -export function findNode(tree: TreeNode[], id: string): TreeNode | undefined { - for (const node of tree) { - if (node.id === id) return node; - if (node.isFolder) { - const found = findNode(node.children, id); - if (found) return found; - } - } - return undefined; -} diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts deleted file mode 100644 index 3cbd498..0000000 --- a/src/routes/(auth)/login/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { resolve } from "$app/paths"; -import { redirect } from "@sveltejs/kit"; - -export const load = (event): void => { - if (event.locals.user) { - redirect(302, resolve("/")); - } -}; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte deleted file mode 100644 index b7e28c1..0000000 --- a/src/routes/(auth)/login/+page.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - -
-
-
-

Log In

- - {#each login.fields.allIssues() as issue (issue.path)} - -
-

{issue.message}

-
- {/each} - -
-
-
- -
- -
- -
- -
- -
-
-
- -
OR
- Don't have an account? Sign up -
-
-
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts deleted file mode 100644 index 3cbd498..0000000 --- a/src/routes/(auth)/signup/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { resolve } from "$app/paths"; -import { redirect } from "@sveltejs/kit"; - -export const load = (event): void => { - if (event.locals.user) { - redirect(302, resolve("/")); - } -}; diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte deleted file mode 100644 index 0d42164..0000000 --- a/src/routes/(auth)/signup/+page.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
-
-
-

Sign Up

- - {#each signup.fields.allIssues() as issue (issue.path)} -
-

{issue.message}

-
- {/each} - -
- - -
-
- -
- -
- -
- -
- -
-
-
- -
OR
- Already have an account? Log in -
-
-
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts deleted file mode 100644 index dd403ee..0000000 --- a/src/routes/+layout.server.ts +++ /dev/null @@ -1,27 +0,0 @@ -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; -} - -export const load = async ({ locals }): Promise => { - const localUser = locals.user; - - if (!localUser) { - return { user: undefined }; - } - 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 }; -}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 70dac07..8757fa1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,24 +1,9 @@ - - - - + {@render children()} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts deleted file mode 100644 index 7b625eb..0000000 --- a/src/routes/+page.server.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { db } from "$lib/server/db"; -import * as table 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(table.notes) - .where( - and(eq(table.notes.ownerId, user.id), eq(table.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 - .select({ - id: table.notes.id, - title: table.notes.title, - updatedAt: table.notes.updatedAt, - }) - .from(table.notes) - .where( - and(eq(table.notes.ownerId, user.id), eq(table.notes.isFolder, false)), - ); - - 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/+page.svelte index 7d2c9b0..f5639d1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,69 +1,5 @@ - - -
-
-

Dashboard

- -
- -
-
-

Total Notes

-
- - {data.totalNotes} - -
-
-
- - - {#if data.randomNote} -
-
-

Random Note

-
-

- {data.randomNote.title} -

-

- Last updated: {new Date( - data.randomNote.updatedAt, - ).toLocaleDateString()} -

-
- -
-
- {:else if data.totalNotes === 0} -
-
-

Get Started

-
-

- You don't have any notes yet. Create your first note to get - started! -

-
- -
-
- {/if} -
-
-
+

Welcome to SvelteKit

+

+ Visit svelte.dev/docs/kit to read the + documentation +

diff --git a/src/routes/api/sync/[noteId]/+server.ts b/src/routes/api/sync/[noteId]/+server.ts deleted file mode 100644 index ee4badc..0000000 --- a/src/routes/api/sync/[noteId]/+server.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { syncSchemaJson } from "$lib/remote/notes.schemas.ts"; -import { db } from "$lib/server/db"; -import * as table from "$lib/server/db/schema.ts"; -import { addClient, removeClient } from "$lib/server/real-time"; -import { json } from "@sveltejs/kit"; -import { eq } from "drizzle-orm"; -import { Schema } from "effect"; - -export const GET = async ({ params, locals }) => { - if (!locals.user) { - return json({ error: "Unauthorized" }, { status: 401 }); - } - - const noteId = params.noteId; - if (!noteId) { - return json({ error: "Note ID required" }, { status: 400 }); - } - - // Verify access - const note = await db - .select() - .from(table.notes) - .where(eq(table.notes.id, noteId)) - .get(); - if (!note || note.ownerId !== locals.user.id) { - // TODO: Add check for shared notes when federation via ATProto is implemented - return json({ error: "Not found or unauthorized" }, { status: 404 }); - } - - // Create a stream for SSE - let controller: ReadableStreamDefaultController>; - const stream = new ReadableStream>({ - start(c) { - controller = c; - addClient(noteId, controller); - // Send initial connection message - const encoder = new TextEncoder(); - c.enqueue( - encoder.encode( - `event: connected\ndata: ${Schema.encodeSync(syncSchemaJson)({ noteId, updates: [] })}\n\n`, - ), - ); - }, - cancel() { - removeClient(noteId, controller); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -}; diff --git a/src/routes/demo/+page.svelte b/src/routes/demo/+page.svelte new file mode 100644 index 0000000..1f5f708 --- /dev/null +++ b/src/routes/demo/+page.svelte @@ -0,0 +1,5 @@ + + +lucia diff --git a/src/routes/demo/lucia/+page.server.ts b/src/routes/demo/lucia/+page.server.ts new file mode 100644 index 0000000..16fe57d --- /dev/null +++ b/src/routes/demo/lucia/+page.server.ts @@ -0,0 +1,30 @@ +import * as auth from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; +import { getRequestEvent } from "$app/server"; + +export const load = () => { + const user = requireLogin(); + return { user }; +}; + +export const actions = { + logout: async (event) => { + if (!event.locals.session) { + return fail(401); + } + await auth.invalidateSession(event.locals.session.id); + auth.deleteSessionTokenCookie(event); + + redirect(302, "/demo/lucia/login"); + }, +}; + +function requireLogin(): auth.User { + const { locals } = getRequestEvent(); + + if (!locals.user) { + redirect(302, "/demo/lucia/login"); + } + + return locals.user; +} diff --git a/src/routes/demo/lucia/+page.svelte b/src/routes/demo/lucia/+page.svelte new file mode 100644 index 0000000..dd819d4 --- /dev/null +++ b/src/routes/demo/lucia/+page.svelte @@ -0,0 +1,11 @@ + + +

Hi, {data.user.username}!

+

Your user ID is {data.user.id}.

+
+ +
diff --git a/src/routes/demo/lucia/login/+page.server.ts b/src/routes/demo/lucia/login/+page.server.ts new file mode 100644 index 0000000..da3d05c --- /dev/null +++ b/src/routes/demo/lucia/login/+page.server.ts @@ -0,0 +1,118 @@ +import { hash, verify } from "@node-rs/argon2"; +import { encodeBase32LowerCase } from "@oslojs/encoding"; +import { fail, redirect } from "@sveltejs/kit"; +import { eq } from "drizzle-orm"; +import * as auth from "$lib/server/auth"; +import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema"; + +export const load = (event) => { + if (event.locals.user) { + redirect(302, "/demo/lucia"); + } + return {}; +}; + +export const actions = { + login: async (event) => { + const formData = await event.request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + + if (!validateUsername(username)) { + return fail(400, { + message: + "Invalid username (min 3, max 31 characters, alphanumeric only)", + }); + } + if (!validatePassword(password)) { + return fail(400, { + message: "Invalid password (min 6, max 255 characters)", + }); + } + + const results = await db + .select() + .from(table.user) + .where(eq(table.user.username, username)); + + const existingUser = results.at(0); + if (!existingUser) { + return fail(400, { message: "Incorrect username or password" }); + } + + const validPassword = await verify(existingUser.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + if (!validPassword) { + return fail(400, { message: "Incorrect username or password" }); + } + + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, existingUser.id); + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + + redirect(302, "/demo/lucia"); + }, + register: async (event) => { + const formData = await event.request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + + if (!validateUsername(username)) { + return fail(400, { message: "Invalid username" }); + } + if (!validatePassword(password)) { + return fail(400, { message: "Invalid password" }); + } + + const userId = generateUserId(); + const passwordHash = await hash(password, { + // recommended minimum parameters + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + try { + await db + .insert(table.user) + .values({ id: userId, username, passwordHash }); + + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, userId); + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + } catch { + return fail(500, { message: "An error has occurred" }); + } + redirect(302, "/demo/lucia"); + }, +}; + +function generateUserId(): string { + // ID with 120 bits of entropy, or about the same as UUID v4. + const bytes = crypto.getRandomValues(new Uint8Array(15)); + const id = encodeBase32LowerCase(bytes); + return id; +} + +function validateUsername(username: unknown): username is string { + return ( + typeof username === "string" && + username.length >= 3 && + username.length <= 31 && + /^[a-z0-9_-]+$/.test(username) + ); +} + +function validatePassword(password: unknown): password is string { + return ( + typeof password === "string" && + password.length >= 6 && + password.length <= 255 + ); +} diff --git a/src/routes/demo/lucia/login/+page.svelte b/src/routes/demo/lucia/login/+page.svelte new file mode 100644 index 0000000..28de1d0 --- /dev/null +++ b/src/routes/demo/lucia/login/+page.svelte @@ -0,0 +1,35 @@ + + +

Login/Register

+
+ + + + +
+

{form?.message ?? ""}

diff --git a/src/routes/layout.css b/src/routes/layout.css index 9a091a3..3571db7 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,35 +1,2 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; -@plugin "daisyui" { - exclude: properties; - logs: false; -} - -:root { - font-family: "Inter", sans-serif; -} - -/* Custom scrollbar for a cleaner look */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} -::-webkit-scrollbar-track { - @apply bg-transparent; -} -::-webkit-scrollbar-thumb { - @apply rounded-full bg-current/25; - - &:hover { - @apply bg-current/50; - } -} - -/* Respect users' accessibility preferences by disabling view transitions. */ -@media (prefers-reduced-motion) { - ::view-transition-group(*), - ::view-transition-old(*), - ::view-transition-new(*) { - animation: none !important; - } -} diff --git a/src/routes/notes/[id]/+layout.svelte b/src/routes/notes/[id]/+layout.svelte deleted file mode 100644 index 8a23e96..0000000 --- a/src/routes/notes/[id]/+layout.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - -
- {#if data.user} - - {/if} - - {@render children()} -
diff --git a/src/routes/notes/[id]/+page.server.ts b/src/routes/notes/[id]/+page.server.ts deleted file mode 100644 index 70f1ff7..0000000 --- a/src/routes/notes/[id]/+page.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getNotes } from "$lib/remote/notes.remote.ts"; -import { guardLogin } from "$lib/server/auth.ts"; -import { error } from "@sveltejs/kit"; - -export const load = async ({ params }): Promise => { - guardLogin(); - - const notesList = await getNotes(); - const note = notesList.find((n) => n.id === params.id); - if (note === undefined) { - error(404, "Note not found"); - } -}; diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte deleted file mode 100644 index cc049cf..0000000 --- a/src/routes/notes/[id]/+page.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - -
- {#if !(note?.isFolder ?? true)} - { - // Hook in Loro - loroManager?.updateContent(newContent); - }} - /> - {:else if note?.isFolder} -
-
-

- - {note.title} -

-

Select a note inside to start editing.

-
-
- {:else} -
-
-
- -
-

No note selected

-

- Select a note from the sidebar or create a new one. -

-
-
- {/if} -
- - -{#if dev} -
-

Selected Note: {id}

-

Loro Manager: {loroManager ? "Loaded" : "Null"}

-

Content Length: {editorContent.length}

-

Content Preview: {editorContent.slice(0, 50)}

-

~Word Count: {editorContent.split(/\s+/).length}

-
-{/if} diff --git a/src/stories/Button.stories.svelte b/src/stories/Button.stories.svelte new file mode 100644 index 0000000..c300d5e --- /dev/null +++ b/src/stories/Button.stories.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/src/stories/Button.svelte b/src/stories/Button.svelte new file mode 100644 index 0000000..058b0c2 --- /dev/null +++ b/src/stories/Button.svelte @@ -0,0 +1,40 @@ + + + diff --git a/src/stories/Header.stories.svelte b/src/stories/Header.stories.svelte new file mode 100644 index 0000000..aed255c --- /dev/null +++ b/src/stories/Header.stories.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/src/stories/Header.svelte b/src/stories/Header.svelte new file mode 100644 index 0000000..41dd3b6 --- /dev/null +++ b/src/stories/Header.svelte @@ -0,0 +1,58 @@ + + +
+
+
+ + + + + + + +

Acme

+
+
+ {#if user} + + Welcome, {user.name}! + +
+
+
diff --git a/src/stories/Page.stories.svelte b/src/stories/Page.stories.svelte new file mode 100644 index 0000000..ea9a5db --- /dev/null +++ b/src/stories/Page.stories.svelte @@ -0,0 +1,31 @@ + + + { + const canvas = within(canvasElement); + const loginButton = canvas.getByRole("button", { name: /Log in/i }); + await expect(loginButton).toBeInTheDocument(); + await userEvent.click(loginButton); + await waitFor(() => expect(loginButton).not.toBeInTheDocument()); + + const logoutButton = canvas.getByRole("button", { name: /Log out/i }); + await expect(logoutButton).toBeInTheDocument(); + }} +/> + + diff --git a/src/stories/Page.svelte b/src/stories/Page.svelte new file mode 100644 index 0000000..b0b1679 --- /dev/null +++ b/src/stories/Page.svelte @@ -0,0 +1,83 @@ + + +
+
(user = { name: "Jane Doe" })} + onLogout={() => (user = undefined)} + onCreateAccount={() => (user = { name: "Jane Doe" })} + /> + +
+

Pages in Storybook

+

+ We recommend building UIs with a + + component-driven + + process starting with atomic components and ending with pages. +

+

+ Render pages with mock data. This makes it easy to build and review page + states without needing to navigate to them in your app. Here are some + handy patterns for managing page data in Storybook: +

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such + data from the "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock + these services out using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at + + Storybook tutorials + + . Read more in the + docs + . +

+
+ Tip + Adjust the width of the canvas with the + + + + + + Viewports addon in the toolbar +
+
+
diff --git a/src/stories/button.css b/src/stories/button.css new file mode 100644 index 0000000..7efe955 --- /dev/null +++ b/src/stories/button.css @@ -0,0 +1,30 @@ +.storybook-button { + display: inline-block; + cursor: pointer; + border: 0; + border-radius: 3em; + font-weight: 700; + line-height: 1; + font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +} +.storybook-button--primary { + background-color: #555ab9; + color: white; +} +.storybook-button--secondary { + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; + background-color: transparent; + color: #333; +} +.storybook-button--small { + padding: 10px 16px; + font-size: 12px; +} +.storybook-button--medium { + padding: 11px 20px; + font-size: 14px; +} +.storybook-button--large { + padding: 12px 24px; + font-size: 16px; +} diff --git a/src/stories/header.css b/src/stories/header.css new file mode 100644 index 0000000..ad77492 --- /dev/null +++ b/src/stories/header.css @@ -0,0 +1,32 @@ +.storybook-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 15px 20px; + font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.storybook-header svg { + display: inline-block; + vertical-align: top; +} + +.storybook-header h1 { + display: inline-block; + vertical-align: top; + margin: 6px 0 6px 10px; + font-weight: 700; + font-size: 20px; + line-height: 1; +} + +.storybook-header button + button { + margin-left: 10px; +} + +.storybook-header .welcome { + margin-right: 10px; + color: #333; + font-size: 14px; +} diff --git a/src/stories/page.css b/src/stories/page.css new file mode 100644 index 0000000..2c9a9e0 --- /dev/null +++ b/src/stories/page.css @@ -0,0 +1,68 @@ +.storybook-page { + margin: 0 auto; + padding: 48px 20px; + max-width: 600px; + color: #333; + font-size: 14px; + line-height: 24px; + font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.storybook-page h2 { + display: inline-block; + vertical-align: top; + margin: 0 0 4px; + font-weight: 700; + font-size: 32px; + line-height: 1; +} + +.storybook-page p { + margin: 1em 0; +} + +.storybook-page a { + color: inherit; +} + +.storybook-page ul { + margin: 1em 0; + padding-left: 30px; +} + +.storybook-page li { + margin-bottom: 8px; +} + +.storybook-page .tip { + display: inline-block; + vertical-align: top; + margin-right: 10px; + border-radius: 1em; + background: #e7fdd8; + padding: 4px 12px; + color: #357a14; + font-weight: 700; + font-size: 11px; + line-height: 12px; +} + +.storybook-page .tip-wrapper { + margin-top: 40px; + margin-bottom: 40px; + font-size: 13px; + line-height: 20px; +} + +.storybook-page .tip-wrapper svg { + display: inline-block; + vertical-align: top; + margin-top: 3px; + margin-right: 4px; + width: 12px; + height: 12px; +} + +.storybook-page .tip-wrapper svg path { + fill: #1ea7fd; +} diff --git a/svelte.config.js b/svelte.config.js index d3826f1..ae48796 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -9,24 +9,18 @@ const config = { kit: { adapter: adapter(), - experimental: { - remoteFunctions: true, - }, - typescript: { config(config) { - config["include"] = /** @type {string[]} */ (config["include"]).map( - (path) => path.replace("vite.config", "*.config"), - ); + config["include"] = [ + .../** @type {string[]} */ (config["include"]).map((path) => + path.replace("vite.config", "*.config"), + ), + // Relative to .svelte-kit/ + "../.storybook/*.ts", + ]; }, }, }, - - compilerOptions: { - experimental: { - async: true, - }, - }, }; export default config; diff --git a/tsconfig.json b/tsconfig.json index 7371a0a..55d131e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "module": "preserve", "moduleResolution": "bundler", "target": "esnext", - "types": [], + "types": ["@vitest/browser-playwright", "node"], "allowImportingTsExtensions": true, // Other Outputs @@ -38,9 +38,6 @@ { "name": "typescript-svelte-plugin", "assumeIsSvelteProject": true - }, - { - "name": "@effect/language-service" } ] } diff --git a/vite.config.ts b/vite.config.ts index 86e5c29..f21b878 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,17 +1,57 @@ -import devtoolsJson from "vite-plugin-devtools-json"; -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig, type Plugin } from "vite"; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { sveltekit } from "@sveltejs/kit/vite"; - -import wasm from "vite-plugin-wasm"; -import topLevelAwait from "vite-plugin-top-level-await"; +import tailwindcss from "@tailwindcss/vite"; +import { playwright } from "@vitest/browser-playwright"; +import path from "node:path"; +import { defineConfig } from "vitest/config"; +import devtoolsJson from "vite-plugin-devtools-json"; export default defineConfig({ - plugins: [ - wasm() as Plugin, - topLevelAwait(), - tailwindcss(), - sveltekit(), - devtoolsJson(), - ], + plugins: [tailwindcss(), sveltekit(), devtoolsJson()], + test: { + expect: { + requireAssertions: true, + }, + coverage: { + enabled: true, + }, + projects: [ + { + extends: true, + test: { + name: "server", + environment: "node", + dir: "src/", + include: ["**/*.{test,spec}.{js,ts}"], + }, + }, + { + extends: true, + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + storybookTest({ + configDir: path.join(import.meta.dirname, ".storybook"), + }), + ], + test: { + name: "storybook", + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [ + { + browser: "chromium", + }, + ], + }, + setupFiles: [".storybook/vitest.setup.ts"], + expect: { + requireAssertions: false, + }, + }, + }, + ], + }, });