forked from mirror/crumbs-original
Compare commits
@@ -98,6 +98,10 @@ tests/
|
||||
unit/ # Vitest unit tests
|
||||
e2e/ # Playwright e2e tests (Gherkin-style)
|
||||
helpers/fixtures.ts # Shared authenticatedPage fixture
|
||||
website/ # Static landing page (crumbs.bretzel.app)
|
||||
index.html # Landing page with feature cards, screenshots, deploy section
|
||||
styles.css # Landing page styles (retro parchment theme)
|
||||
assets/ # Favicon, screenshots
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -215,5 +219,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|
||||
|-----|--------|
|
||||
| `docs/FEATURES.md` | User-facing feature list |
|
||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
|
||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
# Crumbs by Bretzel
|
||||
|
||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -21,7 +21,7 @@ docker compose up -d
|
||||
|
||||
Open http://localhost:3000 and create your admin account on first visit.
|
||||
|
||||
#### With OAuth (optional)
|
||||
#### With SSO (optional)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
@@ -34,14 +34,18 @@ services:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_GOOGLE_ID=your-google-client-id
|
||||
- AUTH_GOOGLE_SECRET=your-google-client-secret
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=your-client-id
|
||||
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
```bash
|
||||
@@ -99,6 +103,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
|----------|-------------|
|
||||
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.8.1
|
||||
image: ghcr.io/bretzel-app/crumbs:0.15.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+117
@@ -321,6 +321,123 @@ Permanently delete a note
|
||||
|
||||
---
|
||||
|
||||
## Collaboration
|
||||
|
||||
### `GET /api/notes/{id}/collaborators`
|
||||
|
||||
List collaborators
|
||||
|
||||
Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email",
|
||||
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/notes/{id}/collaborators`
|
||||
|
||||
Add a collaborator
|
||||
|
||||
Owner-only. Adds a user as collaborator on the note.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email",
|
||||
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/notes/{id}/collaborators`
|
||||
|
||||
Remove a collaborator
|
||||
|
||||
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
| `userId` | query | | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
### `GET /api/users/search`
|
||||
|
||||
Search users
|
||||
|
||||
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `q` | query | string | yes | Search query (matches email or displayName) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"displayName": "displayName",
|
||||
"email": "email"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
### `GET /api/notes/{id}/attachments`
|
||||
|
||||
+15
-3
@@ -83,9 +83,8 @@ For a single-user app with multiple devices:
|
||||
- New v4 uses Vite plugin (faster builds)
|
||||
|
||||
### Argon2
|
||||
- Current best practice for password hashing
|
||||
- Memory-hard (resists GPU attacks)
|
||||
- Winner of the Password Hashing Competition
|
||||
- Memory-hard password hashing (resists GPU attacks)
|
||||
- See [AUTH.md](AUTH.md) for authentication details
|
||||
|
||||
## Database Schema
|
||||
|
||||
@@ -99,8 +98,21 @@ Key tables:
|
||||
- `note_tags` - Many-to-many note ↔ tag
|
||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
|
||||
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
|
||||
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
### Collaboration Access Control
|
||||
|
||||
Notes support sharing with other users on the same instance:
|
||||
|
||||
- **Owner**: Full control (edit, trash, delete, share/unshare)
|
||||
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
|
||||
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
|
||||
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
|
||||
- When owner trashes a note, it vanishes for all collaborators
|
||||
- Collaborators can leave (remove themselves) from shared notes
|
||||
|
||||
## MCP Server
|
||||
|
||||
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Authentication
|
||||
|
||||
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
|
||||
|
||||
## First-run setup
|
||||
|
||||
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
|
||||
|
||||
## Password authentication
|
||||
|
||||
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||
- Sessions use httpOnly cookies with 30-day expiry
|
||||
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||
|
||||
## OAuth / SSO
|
||||
|
||||
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
|
||||
|
||||
### How it works
|
||||
|
||||
1. User clicks an OAuth button on the login page
|
||||
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
|
||||
3. Provider authenticates the user and redirects back to Crumbs
|
||||
4. Crumbs validates the response, extracts the user's email and name
|
||||
5. If a matching user exists (by provider ID or email), a session is created
|
||||
6. If no matching user exists, the login is rejected (invite-only model)
|
||||
|
||||
### Invite-only model
|
||||
|
||||
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||
|
||||
1. Go to **Settings > Users > Invite User**
|
||||
2. Create the user with the **same email** they use on the OAuth provider
|
||||
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
|
||||
|
||||
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Google OAuth
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
|
||||
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
|
||||
|
||||
Setup:
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||
2. Create an OAuth 2.0 Client ID (Web application)
|
||||
3. Add the callback URL above as an authorized redirect URI
|
||||
4. Copy the client ID and secret into your environment
|
||||
|
||||
#### GitHub OAuth
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
|
||||
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
|
||||
|
||||
Setup:
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Create a new OAuth App
|
||||
3. Set the authorization callback URL to the callback URL above
|
||||
4. Copy the client ID and secret into your environment
|
||||
|
||||
#### Generic OIDC
|
||||
|
||||
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
|
||||
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
|
||||
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
|
||||
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
|
||||
|
||||
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
|
||||
|
||||
**Required scopes:** `openid`, `profile`, `email`
|
||||
|
||||
### Provider guides
|
||||
|
||||
#### Authentik
|
||||
|
||||
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
|
||||
|
||||
**1. Create a provider in Authentik:**
|
||||
|
||||
1. Open the Authentik admin panel
|
||||
2. Go to **Applications > Providers > Create**
|
||||
3. Select **OAuth2/OpenID Connect**
|
||||
4. Configure:
|
||||
- **Name:** `Crumbs`
|
||||
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
|
||||
- **Client type:** `Confidential`
|
||||
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||
- **Scopes:** `openid`, `profile`, `email`
|
||||
5. Save and note the **Client ID** and **Client Secret**
|
||||
|
||||
**2. Create an application in Authentik:**
|
||||
|
||||
1. Go to **Applications > Applications > Create**
|
||||
2. Configure:
|
||||
- **Name:** `Crumbs`
|
||||
- **Slug:** `crumbs`
|
||||
- **Provider:** Select the `Crumbs` provider you just created
|
||||
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
|
||||
3. Save
|
||||
|
||||
**3. Configure Crumbs:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=<client-id>
|
||||
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
```
|
||||
|
||||
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
|
||||
|
||||
**4. Pre-create users:**
|
||||
|
||||
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
|
||||
|
||||
#### Keycloak
|
||||
|
||||
1. Create a new client in your Keycloak realm
|
||||
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
|
||||
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
|
||||
|
||||
```env
|
||||
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||
AUTH_OIDC_CLIENT_ID=crumbs
|
||||
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
AUTH_OIDC_DISPLAY_NAME=Keycloak
|
||||
```
|
||||
|
||||
#### Okta / Auth0
|
||||
|
||||
Use the issuer URL from your Okta org or Auth0 tenant:
|
||||
|
||||
```env
|
||||
# Okta
|
||||
AUTH_OIDC_ISSUER=https://your-org.okta.com
|
||||
|
||||
# Auth0
|
||||
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
|
||||
```
|
||||
|
||||
### Docker example with multiple providers
|
||||
|
||||
```yaml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
# Google
|
||||
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||
# GitHub
|
||||
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
|
||||
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
|
||||
# Authentik (OIDC)
|
||||
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
|
||||
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
|
||||
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
All configured providers will appear as buttons on the login page.
|
||||
|
||||
## Security notes
|
||||
|
||||
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
|
||||
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
|
||||
- Passwords are hashed with **Argon2** (never stored in plain text)
|
||||
- Sessions expire after **30 days**
|
||||
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
|
||||
- Always use **HTTPS** in production (via reverse proxy)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
|
||||
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
|
||||
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
|
||||
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
|
||||
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
|
||||
+4
-62
@@ -110,48 +110,9 @@ node build
|
||||
| `PORT` | `3000` | HTTP port |
|
||||
| `NODE_ENV` | `development` | Set to `production` for deployment |
|
||||
|
||||
### OAuth Providers (optional)
|
||||
### OAuth / SSO (optional)
|
||||
|
||||
Enable social login by setting the client ID and secret for any provider. Users who sign in via OAuth are created automatically (invite-only when combined with admin approval).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GOOGLE_ID` | Google OAuth client ID |
|
||||
| `AUTH_GOOGLE_SECRET` | Google OAuth client secret |
|
||||
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
|
||||
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
|
||||
| `AUTH_OIDC_ISSUER` | Generic OIDC issuer URL (e.g. `https://accounts.example.com`) |
|
||||
| `AUTH_OIDC_ID` | OIDC client ID |
|
||||
| `AUTH_OIDC_SECRET` | OIDC client secret |
|
||||
| `AUTH_OIDC_NAME` | Display name for the OIDC button (default: "SSO") |
|
||||
|
||||
**Callback URLs** — register these in your provider's dashboard:
|
||||
|
||||
| Provider | Callback URL |
|
||||
|----------|-------------|
|
||||
| Google | `https://your-domain/api/auth/callback/google` |
|
||||
| GitHub | `https://your-domain/api/auth/callback/github` |
|
||||
| OIDC | `https://your-domain/api/auth/callback/oidc` |
|
||||
|
||||
#### Docker example with Google OAuth
|
||||
|
||||
```yaml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_GOOGLE_ID=123456789.apps.googleusercontent.com
|
||||
- AUTH_GOOGLE_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
|
||||
|
||||
## Health Check
|
||||
|
||||
@@ -163,7 +124,7 @@ docker inspect --format='{{.State.Health.Status}}' crumbs
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes.
|
||||
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -186,28 +147,9 @@ Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI as
|
||||
}
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_notes` | List notes (filter by status/tag) |
|
||||
| `get_note` | Get a note by ID |
|
||||
| `create_note` | Create a new note |
|
||||
| `update_note` | Update a note |
|
||||
| `trash_note` / `restore_note` | Move to/from trash |
|
||||
| `archive_note` / `unarchive_note` | Archive/unarchive |
|
||||
| `delete_note` | Permanently delete |
|
||||
| `search_notes` | Full-text search |
|
||||
| `list_tags` | List all tags |
|
||||
| `pin_note` | Pin/unpin a note |
|
||||
| `reorder_notes` | Set sort orders |
|
||||
| `upload_image` | Attach image from URL |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always use HTTPS in production (via reverse proxy)
|
||||
- Set a strong password (min 8 characters recommended)
|
||||
- The `ORIGIN` variable must match your actual domain for CSRF protection
|
||||
- Sessions expire after 30 days
|
||||
- Passwords are hashed with Argon2
|
||||
- API keys are SHA-256 hashed (never stored in plain text)
|
||||
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
|
||||
|
||||
+20
-16
@@ -25,8 +25,8 @@
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Tables (insert, add/delete rows and columns)
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
@@ -59,7 +59,7 @@
|
||||
- Tags inside code blocks and URLs are ignored
|
||||
|
||||
### Color-Coded Notes
|
||||
12 colors matching Google Keep's palette:
|
||||
12 note colors:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to note theme
|
||||
@@ -80,26 +80,30 @@
|
||||
- Restore notes from trash
|
||||
- Permanently delete from trash
|
||||
|
||||
### Note Sharing / Collaboration
|
||||
- Share notes with other users on the same instance
|
||||
- User search autocomplete for adding collaborators
|
||||
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
|
||||
- Per-user state: pin, archive, and sort order are independent per user
|
||||
- Owner-only actions: trash, delete, share/unshare
|
||||
- Collaborator indicator (Users icon) on shared note cards
|
||||
- Hover popover showing collaborator names
|
||||
- Share dialog accessible from editor toolbar
|
||||
- Collaborators can leave shared notes
|
||||
- Shared notes appear mixed in main list alongside owned notes
|
||||
|
||||
### Full-Text Search
|
||||
- Search across note titles, content, and tags
|
||||
- Real-time search results as you type
|
||||
- Clear search to restore original view
|
||||
- Case-insensitive matching
|
||||
|
||||
### Authentication
|
||||
- Multi-user support with role-based access (admin / user)
|
||||
- First-run setup: create admin account (min 8 chars password)
|
||||
- Argon2 password hashing (industry standard)
|
||||
- Session-based auth with 30-day expiry
|
||||
- Active session management (view, revoke individual or all other sessions)
|
||||
- All routes protected except /login and /setup
|
||||
- Account deletion with password confirmation
|
||||
|
||||
### OAuth / SSO
|
||||
- Optional social login via Google, GitHub, or generic OIDC
|
||||
- OAuth users created automatically on first sign-in
|
||||
- Invite-only mode when combined with admin controls
|
||||
- Configure via environment variables (`AUTH_GOOGLE_ID`, `AUTH_GITHUB_ID`, `AUTH_OIDC_ISSUER`, etc.)
|
||||
### Authentication & OAuth / SSO
|
||||
- Multi-user with role-based access (admin / user)
|
||||
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
|
||||
- Session-based auth with active session management
|
||||
- Invite-only OAuth model with PKCE-secured flows
|
||||
- See [AUTH.md](AUTH.md) for full details and provider setup guides
|
||||
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
|
||||
@@ -168,6 +168,104 @@ paths:
|
||||
'404':
|
||||
description: Note not found
|
||||
|
||||
/api/notes/{id}/collaborators:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: List collaborators
|
||||
description: Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||
tags: [Collaboration]
|
||||
responses:
|
||||
'200':
|
||||
description: Collaborator list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Collaborator'
|
||||
post:
|
||||
summary: Add a collaborator
|
||||
description: Owner-only. Adds a user as collaborator on the note.
|
||||
tags: [Collaboration]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [userId]
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
responses:
|
||||
'201':
|
||||
description: Collaborator added, returns updated list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Collaborator'
|
||||
'400':
|
||||
description: Invalid request (missing userId, owner as collaborator)
|
||||
'409':
|
||||
description: User is already a collaborator
|
||||
delete:
|
||||
summary: Remove a collaborator
|
||||
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||
tags: [Collaboration]
|
||||
parameters:
|
||||
- name: userId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
enum: [self]
|
||||
responses:
|
||||
'200':
|
||||
description: Collaborator removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
|
||||
/api/users/search:
|
||||
get:
|
||||
summary: Search users
|
||||
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||
tags: [Users]
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Search query (matches email or displayName)
|
||||
responses:
|
||||
'200':
|
||||
description: Matching users (max 10)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
|
||||
/api/notes/{id}/attachments:
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -542,6 +640,19 @@ components:
|
||||
data:
|
||||
$ref: '#/components/schemas/NoteInput'
|
||||
|
||||
Collaborator:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
addedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ApiKey:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+5
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.16.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -34,6 +34,10 @@
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
|
||||
Generated
+50
@@ -28,6 +28,18 @@ importers:
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
'@tiptap/extension-table-cell':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table-header':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-table-row':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
'@tiptap/extension-task-item':
|
||||
specifier: ^3.20.1
|
||||
version: 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
|
||||
@@ -1508,6 +1520,27 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-cell@3.20.1':
|
||||
resolution: {integrity: sha512-nSeboFey+MJqZuCQSbVUajQVaGiypeoUPiGd0CJdyfc0oPsBGGqp7XFJtw5HHEbDqEvSHvGNDoZDFsUTAxUyIg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-header@3.20.1':
|
||||
resolution: {integrity: sha512-Xsdgik3Bh5xWSkLaPR/6NDLZR5lgUDIN/yiZ7bLtfTsyGxqEvuW2Y9/j/XP3AfsZzGy2/SuA9bM9zxPcFQxwFA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table-row@3.20.1':
|
||||
resolution: {integrity: sha512-uYE0SdY4J81wn/fg8Gum0TVSTTBekQhRGeSwDnRfBBd9JILBsLotBTt4ucM7JOUjrP9BjS+QFCT9EstAchPHfQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-table': ^3.20.1
|
||||
|
||||
'@tiptap/extension-table@3.20.1':
|
||||
resolution: {integrity: sha512-y2o1De3P/FH80nst1D9pYSwtRvxF87WkNAYGurqCkZ6SjwFq1Ifeh9eY+o79R6SCwcfpvQRytCYd+Yw9o5XAtA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.1
|
||||
'@tiptap/pm': ^3.20.1
|
||||
|
||||
'@tiptap/extension-task-item@3.20.1':
|
||||
resolution: {integrity: sha512-ZdQld5Jzw/b/zl9TSA3KNkucmQhW5lHS/0YEKb+eFvxMwdHTKSpsTQzrfzbJixcdSqsuKJB+uUCwDDB+l0CRlA==}
|
||||
peerDependencies:
|
||||
@@ -4811,6 +4844,23 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-cell@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-header@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table-row@3.20.1(@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-table': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
'@tiptap/extension-table@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
|
||||
'@tiptap/pm': 3.20.1
|
||||
|
||||
'@tiptap/extension-task-item@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
|
||||
|
||||
+104
@@ -72,3 +72,107 @@ body::before {
|
||||
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Table styles for TipTap editor */
|
||||
.tiptap table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap th,
|
||||
.tiptap td {
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.tiptap th {
|
||||
font-weight: 600;
|
||||
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
|
||||
}
|
||||
|
||||
.tiptap td p,
|
||||
.tiptap th p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap .selectedCell {
|
||||
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Task list styles — shared between TipTap editor and markdown-it preview */
|
||||
|
||||
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
|
||||
.tiptap ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li::marker,
|
||||
.prose ul.task-list li::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* TipTap: checkbox is inside a label */
|
||||
.tiptap ul[data-type="taskList"] li label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* TipTap: text content is in a div>p */
|
||||
.tiptap ul[data-type="taskList"] li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li > div p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checked item styling */
|
||||
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Override prose spacing for task lists */
|
||||
.prose ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
padding-left: 0;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.prose ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
padding-left: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+19
-24
@@ -22,36 +22,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// MCP endpoint: authenticate via Bearer token (API key)
|
||||
if (pathname.startsWith('/api/mcp')) {
|
||||
// API routes: authenticate via Bearer token if present
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (token) {
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Check if setup is complete
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
collaborators: Collaborator[];
|
||||
ownerName?: string;
|
||||
}
|
||||
|
||||
const { collaborators, ownerName }: Props = $props();
|
||||
|
||||
let showPopover = $state(false);
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleMouseEnter() {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
showPopover = true;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
clearTimeout(hoverTimeout);
|
||||
showPopover = false;
|
||||
}
|
||||
|
||||
function handleClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
showPopover = !showPopover;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative inline-flex"
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
title="Shared note"
|
||||
onclick={handleClick}
|
||||
data-testid="collaborator-indicator"
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
data-testid="collaborator-popover"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#if ownerName}
|
||||
<li class="text-xs text-[var(--text)]">
|
||||
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
|
||||
</li>
|
||||
{/if}
|
||||
{#each collaborators as collab}
|
||||
<li class="text-xs text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
@@ -23,7 +24,10 @@
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown,
|
||||
Ellipsis
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -35,6 +39,9 @@
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
@@ -110,13 +117,35 @@
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
data-testid="formatting-toolbar"
|
||||
>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class={btnClass(editor?.isActive('bold') ?? false)}
|
||||
class={btnClass(isActive('bold'))}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
>
|
||||
@@ -124,7 +153,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class={btnClass(editor?.isActive('italic') ?? false)}
|
||||
class={btnClass(isActive('italic'))}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
>
|
||||
@@ -132,7 +161,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class={btnClass(editor?.isActive('strike') ?? false)}
|
||||
class={btnClass(isActive('strike'))}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
>
|
||||
@@ -140,7 +169,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
>
|
||||
@@ -153,7 +182,7 @@
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
@@ -164,7 +193,7 @@
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
@@ -172,7 +201,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
@@ -180,7 +209,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
@@ -194,7 +223,7 @@
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
@@ -205,7 +234,7 @@
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
@@ -213,12 +242,20 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<ListChecks size={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -227,7 +264,7 @@
|
||||
<div class="relative" data-dropdown="link">
|
||||
<button
|
||||
onclick={() => toggleDropdown('link')}
|
||||
class={btnClass(editor?.isActive('link') ?? false)}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
@@ -259,7 +296,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!editor?.getAttributes('link').href}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
@@ -269,7 +306,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!editor?.isActive('link')}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
@@ -281,99 +318,123 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- More dropdown -->
|
||||
<div class="relative" data-dropdown="more">
|
||||
<!-- Table dropdown -->
|
||||
<div class="relative" data-dropdown="table">
|
||||
<button
|
||||
onclick={() => toggleDropdown('more')}
|
||||
class={dropdownBtnClass(false)}
|
||||
title="More options"
|
||||
data-testid="format-more"
|
||||
onclick={() => toggleDropdown('table')}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Ellipsis size={iconSize} />
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'more'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
{#if openDropdown === 'table'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
|
||||
disabled={!canUndo()}
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-undo"
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<span>Undo</span>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
|
||||
disabled={!canRedo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('code') ?? false)}
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
<span>Inline code</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
<span>Blockquote</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
<span>Code block</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-row"
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
>
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<AlignCenter size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<AlignRight size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<AlignJustify size={iconSize} />
|
||||
{:else}
|
||||
<AlignLeft size={iconSize} />
|
||||
{/if}
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Align left</span>
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Align center</span>
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Align right</span>
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
@@ -382,4 +443,40 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Block-level formatting -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
class={btnClass()}
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const { src, alt, onClose }: Props = $props();
|
||||
|
||||
let dialogEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
@@ -21,14 +23,21 @@
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image lightbox"
|
||||
tabindex="-1"
|
||||
data-testid="image-lightbox"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -119,14 +119,18 @@
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
||||
<button
|
||||
type="button"
|
||||
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
|
||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -73,8 +74,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -88,15 +90,19 @@
|
||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full cursor-pointer object-cover"
|
||||
loading="lazy"
|
||||
<button
|
||||
type="button"
|
||||
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
|
||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="card-thumbnail"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if featuredAttachments.length > 3}
|
||||
@@ -107,16 +113,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Status indicators (top-right) -->
|
||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||
{#if note.isShared && note.collaborators}
|
||||
<CollaboratorPopover
|
||||
collaborators={note.collaborators}
|
||||
ownerName={note.isOwner ? 'You' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if note.title}
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||
@@ -146,14 +161,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.tags && note.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each note.tags as tag}
|
||||
<TagChip {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
@@ -203,14 +210,25 @@
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{#if note.isShared && !note.isOwner}
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -219,24 +237,3 @@
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article :global(.prose li:has(.task-checkbox)) {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article :global(.task-checkbox) {
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -33,6 +37,7 @@
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
@@ -47,6 +52,36 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
const isOwner = $derived(note?.isOwner !== false);
|
||||
const isShared = $derived(collaboratorsList.length > 0);
|
||||
// Use displayName from first user or 'You' for owner
|
||||
const ownerName = $derived(isOwner ? 'You' : 'Owner');
|
||||
|
||||
function handleCollaboratorsUpdate(updated: Collaborator[]) {
|
||||
collaboratorsList = updated;
|
||||
// Update the note in the store
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) =>
|
||||
n.id === noteId
|
||||
? { ...n, collaborators: updated, isShared: updated.length > 0 }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleShareDialog() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showShareDialog = !showShareDialog;
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
@@ -166,7 +201,7 @@
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
@@ -179,6 +214,13 @@
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -284,6 +326,26 @@
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if isShared}
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -296,3 +358,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
<ShareDialog
|
||||
noteId={noteId}
|
||||
collaborators={collaboratorsList}
|
||||
{ownerName}
|
||||
onClose={() => (showShareDialog = false)}
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { dndzone, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
@@ -44,20 +43,12 @@
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
||||
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each localItems as note (note.id)}
|
||||
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 left-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
|
||||
<NoteCard {note} {onEdit} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
@@ -19,10 +17,11 @@
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
// Sync store with route props
|
||||
// Sync store with route props and reload notes when filter changes
|
||||
$effect(() => {
|
||||
currentFilter.set(filter);
|
||||
selectedTag.set(tag);
|
||||
loadNotes(filter);
|
||||
});
|
||||
|
||||
function openEditor(note: Note) {
|
||||
@@ -55,7 +54,7 @@
|
||||
</script>
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<div class="mx-auto mb-6 hidden max-w-xl md:block">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
@@ -65,18 +64,19 @@
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border)] bg-[var(--primary)] text-white shadow-[var(--card-shadow)] transition-colors hover:bg-[var(--primary-hover)] md:hidden"
|
||||
aria-label="Add a crumb"
|
||||
data-testid="new-note-fab"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
|
||||
{#if filter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
draggable
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<script lang="ts">
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import type { ZXCVBNResult } from 'zxcvbn';
|
||||
|
||||
let { password = '' }: { password: string } = $props();
|
||||
|
||||
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
|
||||
let result = $state<ZXCVBNResult | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (password.length > 0) {
|
||||
import('zxcvbn').then((mod) => {
|
||||
result = mod.default(password);
|
||||
});
|
||||
} else {
|
||||
result = null;
|
||||
}
|
||||
});
|
||||
|
||||
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
|
||||
const colors = [
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface UserResult {
|
||||
id: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
collaborators: Collaborator[];
|
||||
ownerName: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (collaborators: Collaborator[]) => void;
|
||||
}
|
||||
|
||||
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<UserResult[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleSearch(query: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
if (!query.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const users: UserResult[] = await res.json();
|
||||
// Filter out existing collaborators
|
||||
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||
}
|
||||
} catch {
|
||||
// Search failed silently
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
handleSearch(searchQuery);
|
||||
});
|
||||
|
||||
async function addCollaborator(user: UserResult) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Collaborator[] = await res.json();
|
||||
onUpdate(updated);
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
showToast(`Shared with ${user.displayName || user.email}`, 'success');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
|
||||
showToast(err.message || 'Failed to share', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to share note', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCollaborator(userId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = collaborators.filter((c) => c.userId !== userId);
|
||||
onUpdate(updated);
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to remove collaborator', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="share-dialog-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
data-testid="share-dialog"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="relative px-4 pt-3">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="share-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search results dropdown -->
|
||||
{#if searchResults.length > 0}
|
||||
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
|
||||
{#each searchResults as user}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
|
||||
onclick={() => addCollaborator(user)}
|
||||
data-testid="share-user-result"
|
||||
>
|
||||
<span class="font-medium">{user.displayName || 'User'}</span>
|
||||
{#if user.email}
|
||||
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Current collaborators -->
|
||||
<div class="px-4 py-3">
|
||||
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
|
||||
<ul class="space-y-1">
|
||||
<!-- Owner -->
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{ownerName}
|
||||
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
|
||||
</span>
|
||||
</li>
|
||||
<!-- Collaborators -->
|
||||
{#each collaborators as collab}
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => removeCollaborator(collab.userId)}
|
||||
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
|
||||
title="Remove"
|
||||
data-testid="remove-collaborator-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
||||
import Hand from 'lucide-svelte/icons/hand';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
onclick={() => sortMode.set(mode.value)}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
||||
{$sortMode === mode.value
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
||||
data-testid="sort-{mode.value}"
|
||||
aria-label="Sort by {mode.label}"
|
||||
>
|
||||
<mode.icon class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
|
||||
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
@@ -44,7 +44,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
||||
<button
|
||||
class="flex cursor-pointer items-center rounded-sm p-1 transition-colors hover:bg-[var(--border-subtle)]/50"
|
||||
title="Sync status: {status} — click to sync"
|
||||
onclick={() => sync()}
|
||||
data-testid="sync-indicator"
|
||||
>
|
||||
{#if displayStatus === 'synced'}
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
@@ -54,4 +59,4 @@
|
||||
{:else}
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{#if onclick}
|
||||
<button
|
||||
{onclick}
|
||||
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
||||
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
||||
data-testid="tag-chip"
|
||||
|
||||
@@ -15,9 +15,19 @@
|
||||
</script>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
<!-- svelte-ignore css_unused_selector -->
|
||||
<div class="tag-scroll flex gap-2 overflow-x-auto max-md:flex-nowrap md:flex-wrap" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tag-scroll {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tag-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +36,12 @@
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
@@ -57,11 +69,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
></div>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './db/index.js';
|
||||
import { notes, noteCollaborators } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
type EventWithLocals = {
|
||||
locals: App.Locals;
|
||||
@@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) {
|
||||
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can access a note (as owner or collaborator).
|
||||
*/
|
||||
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note) return { canAccess: false, isOwner: false };
|
||||
if (note.userId === userId) return { canAccess: true, isOwner: true };
|
||||
|
||||
const collab = db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.get();
|
||||
return { canAccess: !!collab, isOwner: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that a user can access a note. Throws 404 if no access.
|
||||
*/
|
||||
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(noteId, userId);
|
||||
if (!canAccess) throw error(404, 'Not found');
|
||||
return { isOwner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that a user is the owner of a note. Throws 404 if not owner.
|
||||
*/
|
||||
export function requireNoteOwnership(noteId: string, userId: number): void {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note || note.userId !== userId) throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
@@ -194,10 +194,10 @@ export function revokeAllSessions(userId: number, exceptToken?: string): void {
|
||||
export async function createUser(
|
||||
email: string,
|
||||
displayName: string,
|
||||
password: string,
|
||||
password: string | null,
|
||||
role: 'admin' | 'user' = 'user'
|
||||
): Promise<User> {
|
||||
const hash = await argon2.hash(password);
|
||||
const hash = password ? await argon2.hash(password) : null;
|
||||
const result = db
|
||||
.insert(users)
|
||||
.values({
|
||||
@@ -205,7 +205,7 @@ export async function createUser(
|
||||
displayName,
|
||||
role,
|
||||
passwordHash: hash,
|
||||
authProvider: 'password',
|
||||
authProvider: password ? 'password' : 'none',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { db } from './db/index.js';
|
||||
import { noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Batch fetch collaborators for multiple notes.
|
||||
* Returns a Map of noteId → Collaborator[].
|
||||
*/
|
||||
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
noteId: noteCollaborators.noteId,
|
||||
userId: noteCollaborators.userId,
|
||||
displayName: users.displayName,
|
||||
email: users.email,
|
||||
addedAt: noteCollaborators.addedAt
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(users, eq(noteCollaborators.userId, users.id))
|
||||
.where(inArray(noteCollaborators.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, Collaborator[]>();
|
||||
for (const row of rows) {
|
||||
const collab: Collaborator = {
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
addedAt: row.addedAt
|
||||
};
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(collab);
|
||||
} else {
|
||||
map.set(row.noteId, [collab]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a collaborator to a note.
|
||||
*/
|
||||
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId, addedBy, addedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a collaborator from a note. Also cleans up their per-user state.
|
||||
*/
|
||||
export function removeCollaborator(noteId: string, userId: number): void {
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.run();
|
||||
db.delete(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collaborator user IDs for a note.
|
||||
*/
|
||||
export function getCollaboratorIds(noteId: string): number[] {
|
||||
return db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(eq(noteCollaborators.noteId, noteId))
|
||||
.all()
|
||||
.map((r) => r.userId);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
|
||||
const now = new Date();
|
||||
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedTwoUsers(db);
|
||||
});
|
||||
|
||||
describe('noteCollaborators table', () => {
|
||||
it('should add a collaborator to a note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userId).toBe(2);
|
||||
expect(result[0].addedBy).toBe(1);
|
||||
});
|
||||
|
||||
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(() =>
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should allow multiple collaborators per note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
// Add a third user
|
||||
db.insert(users)
|
||||
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(noteCollaborators).values([
|
||||
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
|
||||
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
|
||||
]).run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove a collaborator', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteUserState table', () => {
|
||||
it('should store per-user pin/archive/sortOrder state', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||
.get();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.pinned).toBe(true);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
expect(() =>
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
|
||||
.run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should default to false/false/0 for new state', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: 2 })
|
||||
.run();
|
||||
|
||||
const result = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||
.get();
|
||||
|
||||
expect(result!.pinned).toBe(false);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow independent state per user per note', () => {
|
||||
seedNote(db, 'n1', 1);
|
||||
db.insert(users)
|
||||
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(noteUserState).values([
|
||||
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
|
||||
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
|
||||
]).run();
|
||||
|
||||
const user2State = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
|
||||
const user3State = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
|
||||
|
||||
expect(user2State!.pinned).toBe(true);
|
||||
expect(user2State!.archived).toBe(false);
|
||||
expect(user3State!.pinned).toBe(false);
|
||||
expect(user3State!.archived).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,21 @@ sqlite.exec(`
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_user_state (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
@@ -168,6 +183,10 @@ sqlite.exec(`
|
||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
@@ -201,6 +220,29 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: make password_hash nullable (for OAuth-only users)
|
||||
const pwCol = (
|
||||
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
|
||||
).find((col) => col.name === 'password_hash');
|
||||
if (pwCol && pwCol.notnull) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE __new_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
password_hash TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'password',
|
||||
provider_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
|
||||
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE __new_users RENAME TO users;
|
||||
`);
|
||||
}
|
||||
|
||||
// Re-enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
|
||||
@@ -128,6 +128,42 @@ export const syncLog = sqliteTable(
|
||||
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
||||
);
|
||||
|
||||
export const noteCollaborators = sqliteTable(
|
||||
'note_collaborators',
|
||||
{
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
addedBy: integer('added_by')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
|
||||
index('note_collaborators_user_id_idx').on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const noteUserState = sqliteTable(
|
||||
'note_user_state',
|
||||
{
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
|
||||
);
|
||||
|
||||
export const loginAttempts = sqliteTable(
|
||||
'login_attempts',
|
||||
{
|
||||
|
||||
@@ -68,6 +68,24 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
|
||||
CREATE TABLE note_user_state (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
|
||||
+263
-73
@@ -1,59 +1,158 @@
|
||||
import { db as defaultDb } from './db/index.js';
|
||||
import { notes, noteTags, tags } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
const db = defaultDb;
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
userId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
trashed: boolean;
|
||||
trashedAt: Date | null;
|
||||
checklistMode: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
const noteIds = noteRows.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(noteIds);
|
||||
|
||||
return noteRows.map((note) => {
|
||||
const collaborators = collabMap.get(note.id) ?? [];
|
||||
const isOwner = note.userId === userId;
|
||||
const isShared = collaborators.length > 0;
|
||||
return {
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? [],
|
||||
collaborators,
|
||||
isOwner,
|
||||
isShared
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared notes for a user with per-user state overlay.
|
||||
*/
|
||||
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
filter === 'archived'
|
||||
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
|
||||
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows as NoteRow[];
|
||||
}
|
||||
|
||||
export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
// Owned notes
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
case 'archived':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||
break;
|
||||
case 'trashed':
|
||||
// Collaborators never see trashed notes — only owner's trashed notes
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||
break;
|
||||
default:
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const result = db
|
||||
const ownedNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
.all() as NoteRow[];
|
||||
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
return result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
// Shared notes (not for trashed filter — collaborators don't see trash)
|
||||
let sharedNotes: NoteRow[] = [];
|
||||
if (filter !== 'trashed') {
|
||||
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
|
||||
}
|
||||
|
||||
const combined = [...ownedNotes, ...sharedNotes];
|
||||
combined.sort((a, b) => {
|
||||
// Pinned first, then by updatedAt descending
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
|
||||
return hydrateNotes(combined, userId);
|
||||
}
|
||||
|
||||
export function getNote(userId: number, id: string) {
|
||||
const note = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (!note) return null;
|
||||
|
||||
const tagMap = fetchTagsForNotes([id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([id]);
|
||||
return {
|
||||
...note,
|
||||
tags: tagMap.get(id) ?? [],
|
||||
attachments: attachmentMap.get(id) ?? []
|
||||
};
|
||||
let effectiveNote = note as NoteRow;
|
||||
|
||||
// For collaborators, overlay per-user state
|
||||
if (!isOwner) {
|
||||
const userState = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
effectiveNote = {
|
||||
...note,
|
||||
pinned: userState?.pinned ?? false,
|
||||
archived: userState?.archived ?? false,
|
||||
sortOrder: userState?.sortOrder ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const result = hydrateNotes([effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -109,56 +208,94 @@ export interface UpdateNoteInput {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
|
||||
|
||||
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (!existing) return null;
|
||||
|
||||
// Collaborators cannot trash notes
|
||||
if (!isOwner && input.trashed !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
|
||||
// Shared fields: update the notes table (all participants can edit content)
|
||||
const sharedUpdates: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
version: existing.version + 1
|
||||
};
|
||||
let hasSharedUpdates = false;
|
||||
|
||||
if (input.title !== undefined) updates.title = input.title;
|
||||
if (input.content !== undefined) updates.content = input.content;
|
||||
if (input.color !== undefined) updates.color = input.color;
|
||||
if (input.pinned !== undefined) updates.pinned = input.pinned;
|
||||
if (input.archived !== undefined) updates.archived = input.archived;
|
||||
if (input.trashed !== undefined) {
|
||||
updates.trashed = input.trashed;
|
||||
updates.trashedAt = input.trashed ? now : null;
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; }
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table directly
|
||||
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
|
||||
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
|
||||
if (input.trashed !== undefined) {
|
||||
sharedUpdates.trashed = input.trashed;
|
||||
sharedUpdates.trashedAt = input.trashed ? now : null;
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode;
|
||||
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
|
||||
|
||||
db.update(notes)
|
||||
.set(updates)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
|
||||
const updated = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
|
||||
if (hasPerUserFields) {
|
||||
const userState = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (userState) {
|
||||
const stateUpdates: Record<string, unknown> = {};
|
||||
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
|
||||
if (input.archived !== undefined) stateUpdates.archived = input.archived;
|
||||
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
|
||||
db.update(noteUserState)
|
||||
.set(stateUpdates)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(noteUserState)
|
||||
.values({
|
||||
noteId: id,
|
||||
userId,
|
||||
pinned: input.pinned ?? false,
|
||||
archived: input.archived ?? false,
|
||||
sortOrder: input.sortOrder ?? 0
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const content = `${updated!.title} ${updated!.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (updated) {
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, existing.userId);
|
||||
}
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([id]);
|
||||
return {
|
||||
...updated,
|
||||
tags: tagMap.get(id) ?? [],
|
||||
attachments: attachmentMap.get(id) ?? []
|
||||
};
|
||||
return getNote(userId, id);
|
||||
}
|
||||
|
||||
export function deleteNote(userId: number, id: string): boolean {
|
||||
@@ -180,7 +317,8 @@ export function searchNotes(userId: number, query: string) {
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const results = db
|
||||
// Owned notes matching query
|
||||
const ownedResults = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
@@ -192,6 +330,40 @@ export function searchNotes(userId: number, query: string) {
|
||||
)
|
||||
.all();
|
||||
|
||||
// Shared notes matching query
|
||||
const sharedResults = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
// Tag search for owned notes
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
@@ -199,12 +371,12 @@ export function searchNotes(userId: number, query: string) {
|
||||
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||
.all();
|
||||
|
||||
const resultIds = new Set(results.map((n) => n.id));
|
||||
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
let extraNotes: typeof results = [];
|
||||
let extraNotes: typeof ownedResults = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
extraNotes = db
|
||||
.select()
|
||||
@@ -213,12 +385,8 @@ export function searchNotes(userId: number, query: string) {
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...results, ...extraNotes];
|
||||
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
|
||||
return combined.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
|
||||
return hydrateNotes(combined, userId);
|
||||
}
|
||||
|
||||
export function listAllTags(userId: number) {
|
||||
@@ -228,9 +396,31 @@ export function listAllTags(userId: number) {
|
||||
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
const now = new Date();
|
||||
for (const { id, sortOrder } of orders) {
|
||||
db.update(notes)
|
||||
.set({ sortOrder, updatedAt: now })
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
if (!canAccess) continue;
|
||||
|
||||
if (isOwner) {
|
||||
db.update(notes)
|
||||
.set({ sortOrder, updatedAt: now })
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
// Collaborator: upsert into noteUserState
|
||||
const existing = db
|
||||
.select()
|
||||
.from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
db.update(noteUserState)
|
||||
.set({ sortOrder })
|
||||
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-13
@@ -3,7 +3,6 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.
|
||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
@@ -28,25 +27,20 @@ export const filteredNotes = derived(
|
||||
}
|
||||
);
|
||||
|
||||
function sortNotes(list: Note[], mode: SortMode): Note[] {
|
||||
function sortNotes(list: Note[]): Note[] {
|
||||
return [...list].sort((a, b) => {
|
||||
if (mode === 'created') {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
if (mode === 'custom') {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
// default: updated
|
||||
const orderDiff = a.sortOrder - b.sortOrder;
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => n.pinned), $sortMode)
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
@@ -233,6 +227,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function leaveNote(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
showToast('Failed to leave note', 'error');
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type SortMode = 'updated' | 'created' | 'custom';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-sort-mode';
|
||||
|
||||
function getInitial(): SortMode {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'updated' || stored === 'created' || stored === 'custom') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export const sortMode = writable<SortMode>(getInitial());
|
||||
|
||||
if (browser) {
|
||||
sortMode.subscribe((value) => {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
import { getChangesSince, processSyncPush } from './server.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users).values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
]).run();
|
||||
}
|
||||
|
||||
function seedSharedNote(id: string, overrides: Partial<typeof notes.$inferInsert> = {}) {
|
||||
const now = new Date();
|
||||
db.insert(notes).values({
|
||||
id,
|
||||
userId: OWNER_ID,
|
||||
title: 'Shared Note',
|
||||
content: 'Original content',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
}).run();
|
||||
|
||||
db.insert(noteCollaborators).values({
|
||||
noteId: id,
|
||||
userId: COLLAB_ID,
|
||||
addedBy: OWNER_ID,
|
||||
addedAt: now
|
||||
}).run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('getChangesSince — shared notes', () => {
|
||||
it('should return shared notes updated after the given timestamp', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].id).toBe('n1');
|
||||
expect(changes[0].title).toBe('Shared Note');
|
||||
expect(changes[0].content).toBe('Original content');
|
||||
});
|
||||
|
||||
it('should not return shared notes updated before the given timestamp', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return per-user state overlay for collaborators', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Owner has pinned=true, archived=false on the note
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, archived: false });
|
||||
|
||||
// Collaborator has pinned=false, archived=true in noteUserState
|
||||
db.insert(noteUserState).values({
|
||||
noteId: 'n1',
|
||||
userId: COLLAB_ID,
|
||||
pinned: false,
|
||||
archived: true,
|
||||
sortOrder: 5
|
||||
}).run();
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Should reflect the collaborator's per-user state, not the owner's
|
||||
expect(changes[0].pinned).toBeFalsy();
|
||||
expect(changes[0].archived).toBeTruthy();
|
||||
expect(changes[0].sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should default to false/0 when collaborator has no noteUserState', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Owner has pinned=true, sortOrder=10
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
|
||||
expect(changes[0].pinned).toBeFalsy();
|
||||
expect(changes[0].sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('should not return trashed notes for collaborators', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return owner edits to a shared note for the collaborator', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
// Simulate owner editing the note
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
db.update(notes)
|
||||
.set({ content: 'Updated by owner', updatedAt: t2, version: 2 })
|
||||
.where(eq(notes.id, 'n1'))
|
||||
.run();
|
||||
|
||||
// Collaborator syncs since t1
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].content).toBe('Updated by owner');
|
||||
expect(changes[0].version).toBe(2);
|
||||
});
|
||||
|
||||
it('should return both owned and shared notes for a user who is both owner and collaborator on different notes', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
|
||||
// Note owned by COLLAB_ID
|
||||
db.insert(notes).values({
|
||||
id: 'owned-by-collab',
|
||||
userId: COLLAB_ID,
|
||||
title: 'My Own Note',
|
||||
content: '',
|
||||
createdAt: t1,
|
||||
updatedAt: t2
|
||||
}).run();
|
||||
|
||||
// Note shared with COLLAB_ID (owned by OWNER_ID)
|
||||
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
const ids = changes.map((c) => c.id).sort();
|
||||
expect(ids).toEqual(['owned-by-collab', 'shared-with-collab']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processSyncPush — shared notes', () => {
|
||||
it('should allow collaborator to update shared content fields', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated by collab', content: 'New content' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Updated by collab');
|
||||
expect(note.content).toBe('New content');
|
||||
expect(note.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should route collaborator per-user fields to noteUserState', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { pinned: true, sortOrder: 3 }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
// Per-user fields should be in noteUserState, not in notes
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.pinned).toBe(false); // Owner's pinned state unchanged
|
||||
|
||||
const userState = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||
.get();
|
||||
expect(userState).toBeDefined();
|
||||
expect(userState!.pinned).toBe(true);
|
||||
expect(userState!.sortOrder).toBe(3);
|
||||
});
|
||||
|
||||
it('should not allow collaborator to trash a note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { trashed: true }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.trashed).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject changes with older timestamp than server note', async () => {
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Stale edit' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
db.insert(notes).values({
|
||||
id: 'n1',
|
||||
userId: OWNER_ID,
|
||||
title: 'Private',
|
||||
content: '',
|
||||
createdAt: t1,
|
||||
updatedAt: t1
|
||||
}).run();
|
||||
|
||||
// Add a third user
|
||||
db.insert(users).values({
|
||||
email: 'stranger@test.com',
|
||||
displayName: 'Stranger',
|
||||
role: 'user',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}).run();
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Hacked' }
|
||||
}], 3, db); // User 3 is not owner or collaborator
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Private'); // Unchanged
|
||||
});
|
||||
|
||||
it('should log sync operations', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated' }
|
||||
}], COLLAB_ID, db);
|
||||
|
||||
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].userId).toBe(COLLAB_ID);
|
||||
expect(logs[0].operation).toBe('update');
|
||||
});
|
||||
});
|
||||
+123
-22
@@ -1,14 +1,18 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and } from 'drizzle-orm';
|
||||
import { db as defaultDb } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
@@ -17,24 +21,78 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
case 'create':
|
||||
case 'update': {
|
||||
if (!change.data) continue;
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (change.timestamp > existing.updatedAt.getTime()) {
|
||||
// Check access: owner can update all fields, collaborator can update shared fields
|
||||
const note = tx.select().from(notes).where(eq(notes.id, change.noteId)).get();
|
||||
|
||||
if (note) {
|
||||
const isOwner = note.userId === userId;
|
||||
const isCollaborator = !isOwner && tx
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, change.noteId), eq(noteCollaborators.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (!isOwner && !isCollaborator) continue;
|
||||
|
||||
if (change.timestamp > note.updatedAt.getTime()) {
|
||||
// Shared fields: title, content, color, checklistMode
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.content !== undefined) sharedData.content = change.data.content;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set({
|
||||
...change.data,
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
})
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
// Only owner can create notes
|
||||
tx.insert(notes)
|
||||
.values({
|
||||
id: change.noteId,
|
||||
@@ -60,6 +118,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Only owner can delete
|
||||
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
|
||||
break;
|
||||
}
|
||||
@@ -78,21 +137,63 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
|
||||
});
|
||||
|
||||
for (const noteId of noteIdsToSyncTags) {
|
||||
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
|
||||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content), userId);
|
||||
syncNoteTags(noteId, extractTags(content), note.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes updated since a given timestamp for a specific user.
|
||||
* Includes both owned and shared notes.
|
||||
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
|
||||
* from noteUserState so collaborators get their own view.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number) {
|
||||
return db
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
const since = new Date(sinceTimestamp);
|
||||
|
||||
// Owned notes
|
||||
const ownedNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
|
||||
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, since)))
|
||||
.all();
|
||||
|
||||
// Shared notes (via collaborator relationship) with per-user state overlay
|
||||
const sharedNotes = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
userId: notes.userId,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
color: notes.color,
|
||||
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||
trashed: notes.trashed,
|
||||
trashedAt: notes.trashedAt,
|
||||
checklistMode: notes.checklistMode,
|
||||
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||
createdAt: notes.createdAt,
|
||||
updatedAt: notes.updatedAt,
|
||||
version: notes.version
|
||||
})
|
||||
.from(noteCollaborators)
|
||||
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||
.leftJoin(
|
||||
noteUserState,
|
||||
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(noteCollaborators.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
gt(notes.updatedAt, since)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
return [...ownedNotes, ...sharedNotes];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@ export interface Note {
|
||||
version: number;
|
||||
tags?: string[];
|
||||
attachments?: Attachment[];
|
||||
collaborators?: Collaborator[];
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
userId: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface NoteCreate {
|
||||
|
||||
@@ -24,6 +24,17 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
token.content = checkbox;
|
||||
tokens[i].children!.unshift(token);
|
||||
|
||||
// Add task-list class to parent <ul> (once)
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (tokens[j].type === 'bullet_list_open') {
|
||||
const cls = tokens[j].attrGet('class') ?? '';
|
||||
if (!cls.includes('task-list')) {
|
||||
tokens[j].attrJoin('class', 'task-list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Cpu size={16} />
|
||||
MCP Server
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
{#if data.user?.role === 'admin'}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
let { data } = $props();
|
||||
|
||||
// Profile state
|
||||
// svelte-ignore state_referenced_locally
|
||||
let displayName = $state(data.user?.displayName || '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let email = $state(data.user?.email || '');
|
||||
let profileMsg = $state('');
|
||||
let profileError = $state(false);
|
||||
@@ -326,7 +328,7 @@
|
||||
<div class="mb-3 text-sm text-[var(--error-text)]">{deleteMsg}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={deletePassword}
|
||||
@@ -335,7 +337,7 @@
|
||||
/>
|
||||
<button
|
||||
onclick={deleteAccount}
|
||||
class="rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
class="whitespace-nowrap rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
let mcpJsonConfig = $derived(`{
|
||||
"mcpServers": {
|
||||
"crumbs": {
|
||||
"type": "streamable-http",
|
||||
"type": "http",
|
||||
"url": "${baseUrl}/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${activeApiKey}"
|
||||
@@ -37,12 +37,12 @@
|
||||
|
||||
let llmInstallPrompt = $derived(`Install the Crumbs MCP server with the following configuration:
|
||||
|
||||
- Type: Streamable HTTP
|
||||
- Type: HTTP (Streamable HTTP)
|
||||
- URL: ${baseUrl}/api/mcp
|
||||
- Authentication: Bearer token in the Authorization header
|
||||
- API Key: ${activeApiKey}
|
||||
|
||||
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json or Claude Desktop's config):
|
||||
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json):
|
||||
|
||||
${mcpJsonConfig}`);
|
||||
|
||||
@@ -122,11 +122,11 @@ ${mcpJsonConfig}`);
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
|
||||
API keys allow external tools to access your notes via the MCP protocol and the REST API.
|
||||
</p>
|
||||
|
||||
<!-- Create Key Form -->
|
||||
<form onsubmit={createKey} class="mb-6 flex gap-2">
|
||||
<form onsubmit={createKey} class="mb-6 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newKeyName}
|
||||
@@ -182,7 +182,7 @@ ${mcpJsonConfig}`);
|
||||
{:else}
|
||||
<div class="space-y-2" data-testid="api-keys-list">
|
||||
{#each keys as key (key.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3" data-testid="api-key-item">
|
||||
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between" data-testid="api-key-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-[var(--text)]" data-testid="api-key-name">{key.name}</span>
|
||||
@@ -195,7 +195,7 @@ ${mcpJsonConfig}`);
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="sm:ml-4">
|
||||
{#if deleteConfirmId === key.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@@ -229,11 +229,21 @@ ${mcpJsonConfig}`);
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- REST API Example -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">REST API</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Use your API key to authenticate requests to any <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">/api/*</code> endpoint:
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 text-xs text-[var(--text)]">curl -H "Authorization: Bearer {activeApiKey}" \
|
||||
{baseUrl}/api/notes</pre>
|
||||
</section>
|
||||
|
||||
<!-- MCP Config Help -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">MCP Client Configuration</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Add this to your MCP client config (e.g. Claude Code settings):
|
||||
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)]">{mcpJsonConfig}</pre>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let users = $state<User[]>(data.users);
|
||||
|
||||
// Create user form
|
||||
@@ -10,17 +11,23 @@
|
||||
let newDisplayName = $state('');
|
||||
let newPassword = $state('');
|
||||
let newRole = $state<'admin' | 'user'>('user');
|
||||
let oauthOnly = $state(false);
|
||||
let createMsg = $state('');
|
||||
let createError = $state(false);
|
||||
|
||||
async function createUser() {
|
||||
createMsg = '';
|
||||
if (!newEmail || !newPassword) {
|
||||
createMsg = 'Email and password are required';
|
||||
if (!newEmail) {
|
||||
createMsg = 'Email is required';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
if (!oauthOnly && !newPassword) {
|
||||
createMsg = 'Password is required (or enable OAuth-only)';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
if (!oauthOnly && newPassword.length < 8) {
|
||||
createMsg = 'Password must be at least 8 characters';
|
||||
createError = true;
|
||||
return;
|
||||
@@ -32,7 +39,7 @@
|
||||
body: JSON.stringify({
|
||||
email: newEmail,
|
||||
displayName: newDisplayName || newEmail.split('@')[0],
|
||||
password: newPassword,
|
||||
password: oauthOnly ? undefined : newPassword,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
@@ -43,6 +50,7 @@
|
||||
newDisplayName = '';
|
||||
newPassword = '';
|
||||
newRole = 'user';
|
||||
oauthOnly = false;
|
||||
createMsg = 'User created';
|
||||
createError = false;
|
||||
} else {
|
||||
@@ -124,13 +132,19 @@
|
||||
placeholder="Display name (optional)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--text)]">
|
||||
<input type="checkbox" bind:checked={oauthOnly} />
|
||||
OAuth-only (no password)
|
||||
</label>
|
||||
{#if !oauthOnly}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
{/if}
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
@@ -153,17 +167,17 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--text)]">
|
||||
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const providers = data.providers ?? [];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
requireAdmin(event);
|
||||
const { email, displayName, password, role } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
throw error(400, 'Email and password are required');
|
||||
if (!email) {
|
||||
throw error(400, 'Email is required');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
if (password && password.length < 8) {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const user = await createUser(email, displayName || email.split('@')[0], password, role || 'user');
|
||||
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
||||
return json(user, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,79 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { listNotes, createNote } from '$lib/server/notes-service.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const filter = url.searchParams.get('filter') || 'all';
|
||||
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
case 'archived':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||
break;
|
||||
case 'trashed':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||
break;
|
||||
default:
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const result = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const notesWithTags = result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(notesWithTags);
|
||||
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
|
||||
const result = listNotes(userId, filter);
|
||||
return json(result);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const now = new Date();
|
||||
const id = body.id || uuidv4();
|
||||
|
||||
const newNote = {
|
||||
id,
|
||||
userId,
|
||||
title: body.title || '',
|
||||
content: body.content || '',
|
||||
color: body.color || 'default',
|
||||
pinned: body.pinned || false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: body.checklistMode || false,
|
||||
sortOrder: body.sortOrder || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
};
|
||||
|
||||
const content = `${newNote.title} ${newNote.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
|
||||
return json({ ...newNote, tags: extractedTags }, { status: 201 });
|
||||
const newNote = createNote(userId, body);
|
||||
return json(newNote, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,98 +1,31 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const note = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
const note = getNote(userId, params.id);
|
||||
if (!note) throw error(404, 'Note not found');
|
||||
|
||||
if (!note) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([note.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([note.id]);
|
||||
|
||||
return json({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
});
|
||||
return json(note);
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
const updated = updateNote(userId, params.id, body);
|
||||
if (!updated) throw error(404, 'Note not found');
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
version: existing.version + 1
|
||||
};
|
||||
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.content !== undefined) updates.content = body.content;
|
||||
if (body.color !== undefined) updates.color = body.color;
|
||||
if (body.pinned !== undefined) updates.pinned = body.pinned;
|
||||
if (body.archived !== undefined) updates.archived = body.archived;
|
||||
if (body.trashed !== undefined) {
|
||||
updates.trashed = body.trashed;
|
||||
updates.trashedAt = body.trashed ? now : null;
|
||||
}
|
||||
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
|
||||
|
||||
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
|
||||
|
||||
const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get();
|
||||
|
||||
if (body.title !== undefined || body.content !== undefined) {
|
||||
const content = `${updated!.title} ${updated!.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(params.id, extractedTags, userId);
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([params.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([params.id]);
|
||||
|
||||
return json({
|
||||
...updated,
|
||||
tags: tagMap.get(params.id) ?? [],
|
||||
attachments: attachmentMap.get(params.id) ?? []
|
||||
});
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
requireNoteOwnership(params.id, userId);
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
const deleted = deleteNote(userId, params.id);
|
||||
if (!deleted) throw error(404, 'Note not found');
|
||||
|
||||
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -2,25 +2,11 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
/** Verify the note belongs to the authenticated user */
|
||||
async function verifyNoteOwnership(noteId: string, userId: number) {
|
||||
const note = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, noteId), eq(notes.userId, userId)))
|
||||
.get();
|
||||
if (!note) throw error(404, 'Note not found');
|
||||
return note;
|
||||
}
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
|
||||
@@ -48,7 +34,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
@@ -73,7 +59,7 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
@@ -89,7 +75,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
await verifyNoteOwnership(params.id, userId);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
return json(collaborators.get(params.id) ?? []);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
|
||||
const body = await request.json();
|
||||
const targetUserId = body.userId;
|
||||
if (!targetUserId || typeof targetUserId !== 'number') {
|
||||
throw error(400, 'userId is required');
|
||||
}
|
||||
|
||||
// Cannot add yourself
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, params.id)).get();
|
||||
if (note && note.userId === targetUserId) {
|
||||
throw error(400, 'Cannot add the owner as a collaborator');
|
||||
}
|
||||
|
||||
// Target user must exist
|
||||
const targetUser = db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get();
|
||||
if (!targetUser) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
// Check not already a collaborator
|
||||
const existing = db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, params.id), eq(noteCollaborators.userId, targetUserId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw error(409, 'User is already a collaborator');
|
||||
}
|
||||
|
||||
addCollaborator(params.id, targetUserId, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
return json(collaborators.get(params.id) ?? [], { status: 201 });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const userIdParam = url.searchParams.get('userId');
|
||||
const targetUserId = userIdParam === 'self' ? userId : Number(userIdParam);
|
||||
if (!targetUserId) {
|
||||
throw error(400, 'userId query parameter is required');
|
||||
}
|
||||
|
||||
const { isOwner } = requireNoteAccess(params.id, userId);
|
||||
|
||||
// Owner can remove anyone. Collaborator can only remove self.
|
||||
if (!isOwner && targetUserId !== userId) {
|
||||
throw error(403, 'Only the owner can remove other collaborators');
|
||||
}
|
||||
|
||||
removeCollaborator(params.id, targetUserId);
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -1,10 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
|
||||
import { and, eq, like, or, inArray } from 'drizzle-orm';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { searchNotes } from '$lib/server/notes-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -14,48 +11,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const results = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
// Also search by tag name
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||
.all();
|
||||
|
||||
const resultIds = new Set(results.map((n) => n.id));
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
let extraNotes: typeof results = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
extraNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...results, ...extraNotes];
|
||||
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
|
||||
const withTags = combined.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(withTags);
|
||||
const results = searchNotes(userId, query);
|
||||
return json(results);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users } from '$lib/server/db/schema.js';
|
||||
import { ne } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const query = url.searchParams.get('q')?.trim();
|
||||
if (!query || query.length < 1) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
const results = db
|
||||
.select({
|
||||
id: users.id,
|
||||
displayName: users.displayName,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(
|
||||
ne(users.id, userId)
|
||||
)
|
||||
.all()
|
||||
.filter(
|
||||
(u) =>
|
||||
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
return json(results);
|
||||
};
|
||||
@@ -70,7 +70,7 @@ test.describe('Organization Features', () => {
|
||||
});
|
||||
|
||||
test.describe('Note Sorting', () => {
|
||||
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
|
||||
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist with different update times
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-First');
|
||||
@@ -82,9 +82,6 @@ test.describe('Note Sorting', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
|
||||
|
||||
// When the user selects "Updated" sort mode
|
||||
await page.getByTestId('sort-updated').click();
|
||||
|
||||
// Then Sort-Second (most recently updated) appears before Sort-First
|
||||
const sortFirst = noteCard(page, 'Sort-First');
|
||||
const sortSecond = noteCard(page, 'Sort-Second');
|
||||
@@ -92,40 +89,4 @@ test.describe('Note Sorting', () => {
|
||||
const secondBox = await sortFirst.boundingBox();
|
||||
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Older');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Older')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Newer');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
|
||||
|
||||
// When the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// Then Created-Newer appears before Created-Older
|
||||
const newer = noteCard(page, 'Created-Newer');
|
||||
const older = noteCard(page, 'Created-Older');
|
||||
const newerBox = await newer.boundingBox();
|
||||
const olderBox = await older.boundingBox();
|
||||
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
|
||||
// Given the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the "Created" sort mode is still active
|
||||
const createdBtn = page.getByTestId('sort-created');
|
||||
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/**
|
||||
* Create a second user via the admin API (using the admin's session cookies).
|
||||
*/
|
||||
async function createCollaboratorUser(adminPage: Page) {
|
||||
const cookies = await adminPage.context().cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name === 'session');
|
||||
if (!sessionCookie) throw new Error('No session cookie found');
|
||||
|
||||
const res = await adminPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
// User may already exist from prior test
|
||||
if (!body.includes('already exists') && res.status() !== 409) {
|
||||
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the collaborator user in a new browser context.
|
||||
*/
|
||||
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
test.describe.serial('Note Sharing', () => {
|
||||
let collabContext: BrowserContext;
|
||||
let collabPage: Page;
|
||||
|
||||
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given a second user exists
|
||||
await createCollaboratorUser(page);
|
||||
|
||||
// When the owner creates a note titled "Shared Note"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shared Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Shared content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
|
||||
// And the owner opens the share dialog
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
|
||||
// And the owner searches for the collaborator
|
||||
await page.getByTestId('share-search-input').fill('collab');
|
||||
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And the owner adds the collaborator
|
||||
await page.getByTestId('share-user-result').first().click();
|
||||
|
||||
// Then the collaborator appears in the share dialog
|
||||
await expect(page.getByTestId('share-collaborator')).toBeVisible();
|
||||
|
||||
// Close dialogs
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note shows a collaborator indicator
|
||||
const card = noteCard(page, 'Shared Note');
|
||||
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given the note was shared in the previous test
|
||||
// When the collaborator logs in
|
||||
const collab = await loginAsCollaborator(browser);
|
||||
collabContext = collab.context;
|
||||
collabPage = collab.page;
|
||||
|
||||
// Then the collaborator sees "Shared Note" in their notes list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// And the note shows a collaborator indicator
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator edits the note
|
||||
await noteCard(collabPage, 'Shared Note').click();
|
||||
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(' — edited by collaborator');
|
||||
await collabPage.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the owner reloads
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the owner sees the collaborator's changes
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator pins the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('pin-btn').click({ force: true });
|
||||
|
||||
// Then the collaborator sees the note as pinned
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
|
||||
|
||||
// But the owner does not see it as pinned
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const ownerCard = noteCard(page, 'Shared Note');
|
||||
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// When the owner edits the shared note content via the API
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
expect(sharedNote).toBeDefined();
|
||||
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by owner for sync test' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the updated content after fetching from the API
|
||||
const collabNotesRes = await collabPage.request.get('/api/notes');
|
||||
const collabNotes = await collabNotesRes.json();
|
||||
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(collabSharedNote).toBeDefined();
|
||||
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
|
||||
|
||||
// And the sync endpoint also returns the change for the collaborator
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by owner for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// Given we have the shared note ID
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// When the collaborator edits the content via the API
|
||||
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by collaborator for sync test' }
|
||||
});
|
||||
|
||||
// Then the owner sees the updated content from the sync endpoint
|
||||
const syncRes = await page.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
|
||||
// Given the shared note exists
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// And the owner pins the note (owner's pin state)
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { pinned: true }
|
||||
});
|
||||
|
||||
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
|
||||
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const collabSyncedNotes = await collabSyncRes.json();
|
||||
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
// Collaborator should see their own pinned state (from noteUserState), not the owner's
|
||||
// The collaborator pinned this note in a previous test, so it should be true for them
|
||||
// but via their own per-user state, not leaked from the owner
|
||||
expect(collabSyncedNote).toBeDefined();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action instead of trash', async () => {
|
||||
// Then the collaborator's card shows a leave button, not a trash button
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await expect(card.getByTestId('leave-btn')).toBeVisible();
|
||||
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator leaves the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('leave-btn').click({ force: true });
|
||||
|
||||
// Then the note disappears from the collaborator's list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// But the owner still sees it
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (collabContext) await collabContext.close();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 850
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, 'src/lib')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
+12
-8
@@ -4,15 +4,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crumbs - Your notes. Your server. Your rules.</title>
|
||||
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
|
||||
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app that respects your privacy. Deploy with a single Docker command.">
|
||||
<meta property="og:title" content="Crumbs by Bretzel">
|
||||
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
|
||||
<meta property="og:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://crumbs.bretzel.app">
|
||||
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="og:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Crumbs by Bretzel">
|
||||
<meta name="twitter:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
|
||||
<meta name="twitter:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
@@ -58,7 +62,7 @@
|
||||
Your notes. Your server.<br><span>Your rules.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle animate-in delay-1">
|
||||
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
|
||||
A self-hostable, offline-first note-taking app that respects your privacy.
|
||||
</p>
|
||||
<div class="hero-buttons animate-in delay-2">
|
||||
<a href="#deploy" class="btn btn-primary">
|
||||
@@ -141,8 +145,8 @@
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
|
||||
</div>
|
||||
<h3>MCP Server</h3>
|
||||
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
|
||||
<h3>API & MCP</h3>
|
||||
<p>Full REST API with token auth. Integrate with n8n, Zapier, or scripts. Built-in MCP server for AI tools like Claude and Cursor.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-User -->
|
||||
@@ -249,7 +253,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.9.0</span>
|
||||
<span class="footer-version">v0.16.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user