From fc856ac09f6085b87808a879ed0b1a0469dbeb56 Mon Sep 17 00:00:00 2001
From: Eli <88557639+lishaduck@users.noreply.github.com>
Date: Sat, 17 Jan 2026 12:21:30 -0600
Subject: [PATCH] chore: wipe
---
.gitignore | 6 +
.mcp.json | 8 +
.prettierrc | 2 +-
.storybook/main.ts | 14 +
.storybook/preview.ts | 16 +
.storybook/vitest.setup.ts | 7 +
.vscode/mcp.json | 52 +-
.vscode/settings.example.json | 2 +-
AGENTS.md | 219 +-
CLAUDE.md | 1 +
CONTRIBUTING.md | 12 +-
drizzle.config.ts | 1 -
.../migration.sql | 39 -
.../snapshot.json | 367 --
eslint.config.ts | 9 +-
knip.config.ts | 1 -
opencode.json | 9 +
package.json | 62 +-
pnpm-lock.yaml | 3974 ++++++++---------
pnpm-workspace.yaml | 16 +-
src/app.d.ts | 3 +-
src/app.html | 6 -
src/demo.spec.ts | 7 +
src/hooks.server.ts | 33 +-
src/lib/components/ProfilePicture.svelte | 13 -
src/lib/components/Sidebar.svelte | 376 --
src/lib/components/TreeItem.svelte | 296 --
.../components/codemirror/Codemirror.svelte | 38 -
src/lib/components/codemirror/Editor.svelte | 205 -
src/lib/components/codemirror/Editor.ts | 261 --
src/lib/components/codemirror/Toolbar.svelte | 31 -
src/lib/crypto.ts | 167 -
src/lib/editor/wikilinks.ts | 89 -
src/lib/loro.ts | 284 --
src/lib/remote/accounts.remote.ts | 93 -
src/lib/remote/accounts.schema.ts | 59 -
src/lib/remote/notes.remote.ts | 173 -
src/lib/remote/notes.schemas.ts | 38 -
src/lib/remote/sync.remote.ts | 35 -
src/lib/schema.ts | 39 -
src/lib/server/auth.ts | 80 +-
src/lib/server/db/index.ts | 9 +-
src/lib/server/db/relations.ts | 31 -
src/lib/server/db/schema.ts | 47 +-
src/lib/server/real-time.ts | 60 -
src/lib/types/uint8array.ts | 80 -
src/lib/unawaited.ts | 5 -
src/lib/utils/tree.ts | 60 -
src/routes/(auth)/login/+page.server.ts | 8 -
src/routes/(auth)/login/+page.svelte | 59 -
src/routes/(auth)/signup/+page.server.ts | 8 -
src/routes/(auth)/signup/+page.svelte | 96 -
src/routes/+layout.server.ts | 27 -
src/routes/+layout.svelte | 17 +-
src/routes/+page.server.ts | 62 -
src/routes/+page.svelte | 74 +-
src/routes/api/sync/[noteId]/+server.ts | 56 -
src/routes/demo/+page.svelte | 5 +
src/routes/demo/lucia/+page.server.ts | 30 +
src/routes/demo/lucia/+page.svelte | 11 +
src/routes/demo/lucia/login/+page.server.ts | 118 +
src/routes/demo/lucia/login/+page.svelte | 35 +
src/routes/layout.css | 33 -
src/routes/notes/[id]/+layout.svelte | 13 -
src/routes/notes/[id]/+page.server.ts | 13 -
src/routes/notes/[id]/+page.svelte | 147 -
src/stories/Button.stories.svelte | 31 +
src/stories/Button.svelte | 40 +
src/stories/Header.stories.svelte | 26 +
src/stories/Header.svelte | 58 +
src/stories/Page.stories.svelte | 31 +
src/stories/Page.svelte | 83 +
src/stories/button.css | 30 +
src/stories/header.css | 32 +
src/stories/page.css | 68 +
svelte.config.js | 20 +-
tsconfig.json | 5 +-
vite.config.ts | 66 +-
78 files changed, 2649 insertions(+), 6088 deletions(-)
create mode 100644 .mcp.json
create mode 100644 .storybook/main.ts
create mode 100644 .storybook/preview.ts
create mode 100644 .storybook/vitest.setup.ts
create mode 120000 CLAUDE.md
delete mode 100644 drizzle/20251211192206_hot_black_widow/migration.sql
delete mode 100644 drizzle/20251211192206_hot_black_widow/snapshot.json
create mode 100644 opencode.json
create mode 100644 src/demo.spec.ts
delete mode 100644 src/lib/components/ProfilePicture.svelte
delete mode 100644 src/lib/components/Sidebar.svelte
delete mode 100644 src/lib/components/TreeItem.svelte
delete mode 100644 src/lib/components/codemirror/Codemirror.svelte
delete mode 100644 src/lib/components/codemirror/Editor.svelte
delete mode 100644 src/lib/components/codemirror/Editor.ts
delete mode 100644 src/lib/components/codemirror/Toolbar.svelte
delete mode 100644 src/lib/crypto.ts
delete mode 100644 src/lib/editor/wikilinks.ts
delete mode 100644 src/lib/loro.ts
delete mode 100644 src/lib/remote/accounts.remote.ts
delete mode 100644 src/lib/remote/accounts.schema.ts
delete mode 100644 src/lib/remote/notes.remote.ts
delete mode 100644 src/lib/remote/notes.schemas.ts
delete mode 100644 src/lib/remote/sync.remote.ts
delete mode 100644 src/lib/schema.ts
delete mode 100644 src/lib/server/db/relations.ts
delete mode 100644 src/lib/server/real-time.ts
delete mode 100644 src/lib/types/uint8array.ts
delete mode 100644 src/lib/unawaited.ts
delete mode 100644 src/lib/utils/tree.ts
delete mode 100644 src/routes/(auth)/login/+page.server.ts
delete mode 100644 src/routes/(auth)/login/+page.svelte
delete mode 100644 src/routes/(auth)/signup/+page.server.ts
delete mode 100644 src/routes/(auth)/signup/+page.svelte
delete mode 100644 src/routes/+layout.server.ts
delete mode 100644 src/routes/+page.server.ts
delete mode 100644 src/routes/api/sync/[noteId]/+server.ts
create mode 100644 src/routes/demo/+page.svelte
create mode 100644 src/routes/demo/lucia/+page.server.ts
create mode 100644 src/routes/demo/lucia/+page.svelte
create mode 100644 src/routes/demo/lucia/login/+page.server.ts
create mode 100644 src/routes/demo/lucia/login/+page.svelte
delete mode 100644 src/routes/notes/[id]/+layout.svelte
delete mode 100644 src/routes/notes/[id]/+page.server.ts
delete mode 100644 src/routes/notes/[id]/+page.svelte
create mode 100644 src/stories/Button.stories.svelte
create mode 100644 src/stories/Button.svelte
create mode 100644 src/stories/Header.stories.svelte
create mode 100644 src/stories/Header.svelte
create mode 100644 src/stories/Page.stories.svelte
create mode 100644 src/stories/Page.svelte
create mode 100644 src/stories/button.css
create mode 100644 src/stories/header.css
create mode 100644 src/stories/page.css
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
-Save
-
...
-Error
-```
-
-### Incorrect
-
-```svelte
-Save
-...
-```
-
-## 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 (user === undefined) {
- throw new Error("Cannot create note whilst logged out.");
- }
-
- await handleCreateNote(
- "An Untitled Note",
- clickedId,
- false,
- user.publicKey,
- );
-
- closeContextMenu();
- }}> New Note Inside
- {/if}
-
-
{
- const noteToRename = notesList.find((n) => n.id === clickedId);
- if (noteToRename) {
- startRename(noteToRename.id, noteToRename.title);
- }
- }}
- >
-
- Rename
-
-
-
handleDelete(clickedId)}
- >
-
- Delete
-
-
-{/if}
-
-
- (renamingId = null)}
->
-
-
Rename
-
{
- if (e.key === "Enter") {
- await handleRename();
- }
- }}
- />
-
- (renamingId = null)} class="btn"> Cancel
-
- Save Changes
-
-
-
-
-
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)}
-
-
-
toggleFolder(item.id)}
- oncontextmenu={(e) => handleContextMenu(e, item.id, true)}
- onkeydown={(e) => e.key === "Enter" && toggleFolder(item.id)}
- >
-
- {#if isExpanded}
-
- {:else}
-
- {/if}
- {item.title}
-
-
-
- {#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 @@
-
-
-
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 @@
-
-
-
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 @@
+
+
+
+ {label}
+
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 @@
+
+
+
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,
+ },
+ },
+ },
+ ],
+ },
});