Compare commits

..
21 Commits
Author SHA1 Message Date
github-actions[bot] c638dce753 chore: bump version to 0.16.1 2026-03-10 06:36:44 +00:00
Frederic CabassutandClaude Opus 4.6 7918c1ecc2 fix: update e2e test for renamed API settings tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:36:03 +01:00
github-actions[bot] 5a32563e3b chore: bump version to 0.16.0 2026-03-09 23:11:31 +00:00
Frederic CabassutandClaude Opus 4.6 1eb10b41bb chore: rename Client Configuration to MCP Client Configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
Frederic CabassutandClaude Opus 4.6 d6eb6d3ad2 feat: extend API key auth to all API endpoints
Bearer token authentication now works on all /api/* routes, not just
/api/mcp. If a Bearer token is present it's validated first; otherwise
session cookie auth is used as fallback. Updates settings UI to reflect
the broader scope (tab renamed to "API", REST API curl example added)
and updates the landing page feature card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
github-actions[bot] f1e18ab968 chore: pin docker-compose to v0.15.0 [skip ci] 2026-03-09 23:02:47 +00:00
github-actions[bot] 707b80ad19 chore: bump version to 0.15.0 2026-03-09 22:57:53 +00:00
Frederic CabassutandClaude Opus 4.6 b42dedfd53 feat: add task list button to rich text editor toolbar
Register TaskList and TaskItem TipTap extensions, add "Task list" option
to the List dropdown in the formatting toolbar, and consolidate task list
CSS between the editor and markdown-it preview into shared styles in
app.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:57:29 +01:00
github-actions[bot] fcbdbd58f8 chore: pin docker-compose to v0.14.0 [skip ci] 2026-03-09 22:42:53 +00:00
github-actions[bot] 103c24c975 chore: bump version to 0.14.0 2026-03-09 22:38:22 +00:00
Frederic CabassutandClaude Opus 4.6 2c9ba1f03d fix: toolbar buttons now reactively show active formatting state
All isActive() and getAttributes() calls in the template were not
reading the tick prop, so Svelte 5 never re-evaluated them when the
editor state changed. Extracted isActive() and getAttrs() helpers
that consume tick to establish the reactive dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:37:38 +01:00
Frederic CabassutandClaude Opus 4.6 a069a2df2b refactor: make toolbar wrap instead of scroll, fix overflow
Use flex-wrap instead of overflow-x-auto for the formatting toolbar —
scrolling clips absolutely-positioned dropdown popovers (CSS forces
both axes to auto). All buttons are now always visible, wrapping to a
second line on narrow screens. Remove the More dropdown entirely since
all items are top-level. Add overflow-hidden to the editor card to
prevent horizontal overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:30:21 +01:00
Frederic CabassutandClaude Opus 4.6 2e302463fb refactor: promote all formatting buttons to top-level on desktop
Move undo/redo to the start of the toolbar, promote inline code,
blockquote, code block, and divider to top-level desktop buttons.
The More dropdown is now mobile-only since all items are directly
accessible on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:24:08 +01:00
Frederic CabassutandClaude Opus 4.6 00b2d54eac refactor: expand toolbar on desktop with alignment dropdown
- Widen editor modal on desktop (max-w-xl → md:max-w-2xl)
- Extract text alignment from More into its own dropdown with contextual
  icon reflecting the active alignment
- Promote undo/redo to top-level buttons on desktop (hidden on mobile,
  still available in More dropdown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:22:01 +01:00
Frederic CabassutandClaude Opus 4.6 dbe040322c feat: add row/column management to table toolbar
Replace single "Insert table" button with a Table dropdown that shows
contextual row/column operations when the cursor is inside a table:
add row, add column, delete row, delete column, delete table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:19:14 +01:00
Frederic CabassutandClaude Opus 4.6 c1e2cf3c8b feat: add table support to TipTap editor
Install @tiptap/extension-table packages, register Table/TableRow/
TableHeader/TableCell extensions, add Table button to the More dropdown
(inserts a 3×3 grid), and style tables to match the parchment theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:15:17 +01:00
Frederic CabassutandClaude Opus 4.6 3df2630fc2 docs: remove tables from FEATURES.md — not implemented
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:05:27 +01:00
github-actions[bot] f45406a76b chore: pin docker-compose to v0.13.1 [skip ci] 2026-03-09 21:47:39 +00:00
github-actions[bot] 435b5a74ff chore: bump version to 0.13.1 2026-03-09 21:43:19 +00:00
Frederic CabassutandClaude Opus 4.6 8b07e41132 fix: anchor collaborator popover to right edge to prevent overflow
The popover is inside a top-right positioned indicator, so anchoring
to right-0 keeps it on screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:39:32 +01:00
github-actions[bot] 5b32a46ca8 chore: pin docker-compose to v0.13.0 [skip ci] 2026-03-09 21:36:34 +00:00
17 changed files with 407 additions and 141 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.12.1
image: ghcr.io/bretzel-app/crumbs:0.15.0
container_name: crumbs
ports:
- "3000:3000"
+1 -1
View File
@@ -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
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.13.0",
"version": "0.16.1",
"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",
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -47,7 +47,7 @@
{#if showPopover}
<div
class="absolute left-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]"
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>
+179 -82
View File
@@ -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>
-21
View File
@@ -237,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>
+1 -1
View File
@@ -201,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); }}
+12
View File
@@ -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,
+11
View File
@@ -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;
}
}
}
}
});
+1 -1
View File
@@ -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'}
+12 -2
View File
@@ -122,7 +122,7 @@ ${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 -->
@@ -229,9 +229,19 @@ ${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 <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
</p>
+3 -3
View File
@@ -57,10 +57,10 @@ test.describe('Settings — API Key Management', () => {
// Given the user is on the settings page
await page.goto('/settings');
// When the user clicks the MCP Server nav link
await page.getByRole('link', { name: 'MCP Server' }).click();
// When the user clicks the API nav link
await page.getByRole('link', { name: 'API' }).click();
// Then the MCP settings page is shown
// Then the API settings page is shown
await expect(page).toHaveURL('/settings/mcp');
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
+3 -3
View File
@@ -145,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 -->
@@ -253,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.13.0</span>
<span class="footer-version">v0.16.1</span>
</div>
</footer>