mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
+75
-18
@@ -48,13 +48,20 @@ jobs:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -65,13 +72,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -83,17 +83,74 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, build]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -104,7 +161,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
needs: [bump, merge]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -121,5 +121,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t crumbs:ci .
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
provenance: false
|
||||
tags: crumbs:ci
|
||||
cache-from: type=gha,scope=ci-docker
|
||||
cache-to: type=gha,scope=ci-docker,mode=max
|
||||
|
||||
@@ -13,10 +13,18 @@ jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: ci
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,13 +33,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -43,17 +44,71 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -63,7 +118,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.24.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.1
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.25.0",
|
||||
"version": "0.28.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
|
||||
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
export const ADMIN = {
|
||||
email: 'admin@bretzel.app',
|
||||
displayName: 'Bretzel Admin',
|
||||
password: 'bretzel2026!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
export const COLLABORATOR = {
|
||||
email: 'salzig@bretzel.app',
|
||||
displayName: 'Salzig Bretzel',
|
||||
password: 'salzig2026!!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
+49
-1
@@ -135,12 +135,20 @@ body::before {
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Nested task lists take full width below the parent item */
|
||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||
.prose ul.task-list li > ul.task-list {
|
||||
flex-basis: 100%;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li::marker,
|
||||
.prose ul.task-list li::marker {
|
||||
content: none;
|
||||
@@ -151,6 +159,7 @@ body::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
@@ -158,6 +167,7 @@ body::before {
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -195,3 +205,41 @@ body::before {
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checklist link styles */
|
||||
.checklist-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checklist-link:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
[data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Custom tooltips */
|
||||
.tooltip-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
color: var(--bg-surface);
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.tooltip-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem } from '$lib/utils/checklist.js';
|
||||
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
|
||||
import LinkPopover from './LinkPopover.svelte';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -19,6 +20,7 @@
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
||||
|
||||
// Track the last content we emitted so we can distinguish self-originated
|
||||
// changes from external ones (sync, history restore, etc.)
|
||||
@@ -85,10 +87,10 @@
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(id: string, text: string) {
|
||||
function updateText(id: string, innerHTML: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
item.text = unlinkifyHtml(innerHTML);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
@@ -106,7 +108,7 @@
|
||||
emitChange();
|
||||
const newId = newItem.id;
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -133,11 +135,11 @@
|
||||
e.preventDefault();
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]'));
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
|
||||
const current = inputs.findIndex((el) => el.dataset.itemId === id);
|
||||
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
|
||||
if (target >= 0 && target < inputs.length) {
|
||||
@@ -155,6 +157,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Svelte action: sets innerHTML on mount and syncs from external changes only.
|
||||
// While the user is typing (element is focused), we don't re-render — that would
|
||||
// destroy the cursor. Linkification happens on blur via the onblur handler.
|
||||
function editableContent(node: HTMLElement, text: string) {
|
||||
node.innerHTML = linkifyText(text);
|
||||
return {
|
||||
update(newText: string) {
|
||||
if (document.activeElement !== node) {
|
||||
node.innerHTML = linkifyText(newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleLinkClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
// --- Directional lock drag handle ---
|
||||
// Instead of blocking DnD events (which corrupts its internal state),
|
||||
// we detect horizontal intent and store the pending operation. DnD runs
|
||||
@@ -276,16 +316,30 @@
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
contenteditable="true"
|
||||
use:editableContent={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
onclick={handleLinkClick}
|
||||
onpaste={handlePaste}
|
||||
onblur={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected && !el.contains(document.activeElement)) {
|
||||
el.innerHTML = linkifyText(item.text);
|
||||
}
|
||||
});
|
||||
}}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-placeholder="List item"
|
||||
aria-placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
data-item-id={item.id}
|
||||
/>
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
></div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
@@ -324,7 +378,7 @@
|
||||
{#if group.parentLabel}
|
||||
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
|
||||
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
|
||||
<span class="text-sm text-[var(--text-muted)]">{group.parentLabel.text}</span>
|
||||
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each group.children as item (item.id)}
|
||||
@@ -336,7 +390,7 @@
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
@@ -352,5 +406,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPopover}
|
||||
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
anchor: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { url, anchor, onClose }: Props = $props();
|
||||
|
||||
let popoverEl: HTMLDivElement | undefined = $state();
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
if (!popoverEl) return;
|
||||
const rect = popoverEl.getBoundingClientRect();
|
||||
|
||||
// Position below the anchor link, or above if near viewport bottom
|
||||
const spaceBelow = window.innerHeight - anchor.bottom;
|
||||
if (spaceBelow >= rect.height + 8) {
|
||||
top = anchor.bottom + 4;
|
||||
} else {
|
||||
top = anchor.top - rect.height - 4;
|
||||
}
|
||||
|
||||
left = anchor.left;
|
||||
// Clamp to viewport
|
||||
if (left + rect.width > window.innerWidth - 8) {
|
||||
left = window.innerWidth - rect.width - 8;
|
||||
}
|
||||
if (left < 8) left = 8;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverEl && !popoverEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={popoverEl}
|
||||
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-testid="link-popover"
|
||||
>
|
||||
<button
|
||||
onclick={openLink}
|
||||
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
|
||||
data-testid="link-popover-open"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
@@ -12,6 +12,8 @@
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -117,7 +119,7 @@
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
use:tooltip={"Unpin"}
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
@@ -130,7 +132,10 @@
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
|
||||
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
|
||||
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-testid={item.indented ? 'card-checklist-child' : undefined}>
|
||||
@@ -141,7 +146,7 @@
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
@@ -160,7 +165,7 @@
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Restore"
|
||||
use:tooltip={"Restore"}
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Undo2 class="h-4 w-4" />
|
||||
@@ -168,7 +173,7 @@
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete forever"
|
||||
use:tooltip={"Delete forever"}
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
@@ -178,7 +183,7 @@
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Pin"
|
||||
use:tooltip={"Pin"}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
@@ -188,7 +193,7 @@
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Unarchive"
|
||||
use:tooltip={"Unarchive"}
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
@@ -197,7 +202,7 @@
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Archive"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
@@ -207,7 +212,7 @@
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
use:tooltip={"Leave note"}
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
@@ -216,7 +221,7 @@
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
use:tooltip={"Trash"}
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
@@ -24,14 +24,21 @@
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import Globe from 'lucide-svelte/icons/globe';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ListX from 'lucide-svelte/icons/list-x';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -41,7 +48,7 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -56,6 +63,100 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// Auto-save: track last-saved state to detect real changes
|
||||
let lastSavedTitle = note?.title ?? '';
|
||||
let lastSavedContent = note?.content ?? '';
|
||||
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||
let isSaving = false;
|
||||
let savingPromise: Promise<void> | null = null;
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||
// from the original. Track whether auto-save should be active yet.
|
||||
let autoSaveReady = isNew;
|
||||
|
||||
function hasUnsavedChanges(): boolean {
|
||||
return (
|
||||
title !== lastSavedTitle ||
|
||||
content !== lastSavedContent ||
|
||||
color !== lastSavedColor ||
|
||||
checklistMode !== lastSavedChecklistMode
|
||||
);
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (isSaving) return;
|
||||
// Snapshot current fields to avoid races with user edits during await
|
||||
const snap = { title, content, color, checklistMode };
|
||||
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||
if (
|
||||
snap.title === lastSavedTitle &&
|
||||
snap.content === lastSavedContent &&
|
||||
snap.color === lastSavedColor &&
|
||||
snap.checklistMode === lastSavedChecklistMode
|
||||
) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
if (currentlyNew) {
|
||||
const created = await createNote(snap);
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
} else if (noteId) {
|
||||
const updated = await updateNote(noteId, snap);
|
||||
if (updated) {
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: triggers 2s after the user stops editing
|
||||
$effect(() => {
|
||||
const _t = title;
|
||||
const _c = content;
|
||||
const _col = color;
|
||||
const _cm = checklistMode;
|
||||
|
||||
if (!autoSaveReady) {
|
||||
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||
lastSavedContent = _c;
|
||||
autoSaveReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||
if (!_t.trim() && !_c.trim()) return;
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
savingPromise = performSave();
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(autoSaveTimer);
|
||||
});
|
||||
|
||||
// Safety net: save on tab close / navigation
|
||||
$effect(() => {
|
||||
function handleBeforeUnload() {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
@@ -70,6 +171,29 @@
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showOverflowMenu = $state(false);
|
||||
let overflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowMenuEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Close overflow menu when clicking outside
|
||||
$effect(() => {
|
||||
if (!showOverflowMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
// Use setTimeout to avoid the current click from closing the menu
|
||||
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
const hasDoneItems = $derived(
|
||||
checklistMode && content.includes('[x]')
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -158,7 +282,13 @@
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
const updated = [...attachmentsList, attachment];
|
||||
attachmentsList = updated;
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
@@ -170,9 +300,14 @@
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
const updated = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
attachmentsList = updated;
|
||||
// Keep the notes store in sync so the card reflects featured state
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
@@ -185,7 +320,11 @@
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
attachmentsList = updated;
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
@@ -196,11 +335,11 @@
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
});
|
||||
|
||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||
// pointer events are never synthetically re-dispatched from touch, so
|
||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||
// pass through to NoteCards underneath (ghost click).
|
||||
let pointerdownOnOverlay = false;
|
||||
let closing = $state(false);
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
@@ -211,35 +350,37 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
// Wait for any in-flight auto-save to finish
|
||||
if (savingPromise) await savingPromise;
|
||||
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
// Final save if auto-save hasn't caught up yet
|
||||
if (hasUnsavedChanges()) {
|
||||
await performSave();
|
||||
}
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
clearTimeout(autoSaveTimer);
|
||||
if (savingPromise) await savingPromise;
|
||||
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||
if (!title.trim()) title = 'Untitled';
|
||||
await performSave();
|
||||
return noteId;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
@@ -267,16 +408,45 @@
|
||||
requestAnimationFrame(() => textareaEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await archiveNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrash() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await trashNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCheckedItems() {
|
||||
const items = parseChecklist(content).filter((i) => !i.checked);
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
|
||||
function uncheckAllItems() {
|
||||
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
class="fixed inset-0 z-40 {closing ? '' : 'flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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]"
|
||||
@@ -358,7 +528,7 @@
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Background color"
|
||||
use:tooltip={"Background color"}
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
@@ -370,51 +540,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
use:tooltip={"Attachments"}
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
<!-- Share button -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
use:tooltip={"Share"}
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
@@ -426,22 +567,33 @@
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
<!-- Archive button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-note-btn"
|
||||
>
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
<Archive class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={overflowBtnEl}
|
||||
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -453,6 +605,98 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowBtnEl}
|
||||
{@const rect = overflowBtnEl.getBoundingClientRect()}
|
||||
<div
|
||||
bind:this={overflowMenuEl}
|
||||
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
|
||||
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-4 w-4" />
|
||||
Switch to text
|
||||
{:else}
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
Checklist mode
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Markdown mode toggle (not available in checklist mode) -->
|
||||
{#if !checklistMode}
|
||||
<button
|
||||
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-4 w-4" />
|
||||
Rich text mode
|
||||
{:else}
|
||||
<FileCode class="h-4 w-4" />
|
||||
Markdown mode
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
Version history
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Trash (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={handleTrash}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
|
||||
data-testid="trash-note-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Move to trash
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Checklist-specific actions -->
|
||||
{#if checklistMode}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{#if hasDoneItems}
|
||||
<button
|
||||
onclick={deleteCheckedItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="delete-checked-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete checked
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={uncheckAllItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
Uncheck all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -16,6 +18,7 @@
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
let newNoteChecklist = $state(false);
|
||||
|
||||
// Sync store with route props and reload notes when filter changes
|
||||
$effect(() => {
|
||||
@@ -32,6 +35,7 @@
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
newNoteChecklist = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
@@ -55,14 +59,24 @@
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<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)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
|
||||
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
use:tooltip={"New checklist"}
|
||||
data-testid="new-checklist-btn"
|
||||
>
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
@@ -119,7 +133,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute right-0 bottom-full z-20 mb-1 min-w-[180px] 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 top-full z-20 mt-1 min-w-[180px] 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="sharing-popover"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
@@ -238,21 +238,20 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// 3-way merge to preserve concurrent edits from other users
|
||||
if (input.content !== existing.content) {
|
||||
const baseSnapshot = input.baseVersion !== undefined
|
||||
? db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get()
|
||||
: db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, id))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
// 3-way merge to preserve concurrent edits from other users.
|
||||
// Only attempt merge when there's a concurrent edit: the client's
|
||||
// baseVersion is behind the current version, meaning someone else
|
||||
// saved between the client's load and this save.
|
||||
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||
&& input.baseVersion < existing.version;
|
||||
|
||||
if (hasConcurrentEdit && input.content !== existing.content) {
|
||||
const baseSnapshot = db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
|
||||
describe('parseChecklist', () => {
|
||||
it('parses flat checklist items', () => {
|
||||
@@ -220,3 +220,107 @@ describe('outdentItem', () => {
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('converts https URLs to anchor tags', () => {
|
||||
expect(linkifyText('visit https://example.com today')).toBe(
|
||||
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts http URLs to anchor tags', () => {
|
||||
expect(linkifyText('go to http://example.com')).toBe(
|
||||
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts bare www URLs with https href', () => {
|
||||
expect(linkifyText('check www.example.com')).toBe(
|
||||
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple URLs in one string', () => {
|
||||
const result = linkifyText('see https://a.com and https://b.com');
|
||||
expect(result).toContain('href="https://a.com"');
|
||||
expect(result).toContain('href="https://b.com"');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged when no URLs present', () => {
|
||||
expect(linkifyText('just some text')).toBe('just some text');
|
||||
});
|
||||
|
||||
it('HTML-escapes user content to prevent XSS', () => {
|
||||
expect(linkifyText('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('HTML-escapes text around URLs', () => {
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('<b>bold</b>');
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at start of string', () => {
|
||||
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at end of string', () => {
|
||||
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('does not linkify partial matches without protocol or www', () => {
|
||||
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
|
||||
});
|
||||
|
||||
it('handles URLs with paths and query strings', () => {
|
||||
const url = 'https://example.com/path?q=1&b=2';
|
||||
const result = linkifyText(url);
|
||||
expect(result).toContain('href="https://example.com/path?q=1&b=2"');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from URLs', () => {
|
||||
expect(linkifyText('see https://example.com.')).toBe(
|
||||
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkifyHtml', () => {
|
||||
it('strips anchor tags and returns text content', () => {
|
||||
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
|
||||
.toBe('visit https://example.com today');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged', () => {
|
||||
expect(unlinkifyHtml('just text')).toBe('just text');
|
||||
});
|
||||
|
||||
it('handles multiple anchor tags', () => {
|
||||
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
|
||||
.toBe('https://a.com and https://b.com');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(unlinkifyHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles div and br tags from contenteditable', () => {
|
||||
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
|
||||
});
|
||||
|
||||
it('decodes HTML entities after stripping tags', () => {
|
||||
expect(unlinkifyHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(unlinkifyHtml('<b>bold</b>')).toBe('<b>bold</b>');
|
||||
expect(unlinkifyHtml('say "hello"')).toBe('say "hello"');
|
||||
});
|
||||
|
||||
it('converts from contenteditable to regular spaces', () => {
|
||||
expect(unlinkifyHtml('hello world')).toBe('hello world');
|
||||
expect(unlinkifyHtml('a b')).toBe('a b');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,3 +150,50 @@ export function serializeChecklist(items: ChecklistItem[]): string {
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
|
||||
|
||||
export function linkifyText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const matches: { index: number; match: string; href: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
const regex = new RegExp(URL_REGEX.source, 'g');
|
||||
|
||||
while ((m = regex.exec(text)) !== null) {
|
||||
const match = m[0];
|
||||
const href = match.startsWith('www.') ? `https://${match}` : match;
|
||||
matches.push({ index: m.index, match, href });
|
||||
}
|
||||
|
||||
if (matches.length === 0) return escapeHtml(text);
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
for (const { index, match, href } of matches) {
|
||||
result += escapeHtml(text.slice(lastIndex, index));
|
||||
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
|
||||
lastIndex = index + match.length;
|
||||
}
|
||||
result += escapeHtml(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unlinkifyHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Custom tooltip action — drop-in replacement for title attributes.
|
||||
* Usage: <button use:tooltip={"Label"}>
|
||||
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
|
||||
*/
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom';
|
||||
|
||||
interface TooltipOptions {
|
||||
text: string;
|
||||
position?: TooltipPosition;
|
||||
}
|
||||
|
||||
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
|
||||
if (typeof value === 'string') return { text: value, position: 'top' };
|
||||
return { text: value.text, position: value.position ?? 'top' };
|
||||
}
|
||||
|
||||
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
|
||||
let opts = parseOptions(value);
|
||||
let el: HTMLDivElement | null = null;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function show() {
|
||||
timeout = setTimeout(() => {
|
||||
if (!opts.text) return;
|
||||
el = document.createElement('div');
|
||||
el.className = 'tooltip-popup';
|
||||
el.textContent = opts.text;
|
||||
document.body.appendChild(el);
|
||||
position();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!el) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const tipRect = el.getBoundingClientRect();
|
||||
|
||||
let top: number;
|
||||
if (opts.position === 'bottom') {
|
||||
top = rect.bottom + 6;
|
||||
} else {
|
||||
top = rect.top - tipRect.height - 6;
|
||||
}
|
||||
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
// Clamp to viewport
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
|
||||
|
||||
el.style.top = `${top}px`;
|
||||
el.style.left = `${left}px`;
|
||||
el.classList.add('tooltip-visible');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearTimeout(timeout);
|
||||
if (el) {
|
||||
el.remove();
|
||||
el = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('pointerdown', hide);
|
||||
// Remove native title to prevent double tooltip
|
||||
const nativeTitle = node.getAttribute('title');
|
||||
if (nativeTitle) node.removeAttribute('title');
|
||||
|
||||
return {
|
||||
update(newValue: string | TooltipOptions) {
|
||||
opts = parseOptions(newValue);
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('pointerdown', hide);
|
||||
if (nativeTitle) node.setAttribute('title', nativeTitle);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
@@ -80,7 +81,7 @@
|
||||
disabled
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.24.0",
|
||||
"version": "0.27.1",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { TEST_CREDENTIALS_FILE } from './global-setup';
|
||||
|
||||
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
test.describe.serial('Authentication', () => {
|
||||
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
|
||||
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
|
||||
data: {
|
||||
email: 'user2@test.com',
|
||||
displayName: 'User Two',
|
||||
password: 'userpassword123',
|
||||
password: userPassword,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Auto-save', () => {
|
||||
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
|
||||
// When the user starts a new note and types a title and content
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-saved Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('This should be saved automatically');
|
||||
|
||||
// Then the note is persisted after the auto-save delay (2s debounce)
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And closing the editor preserves the note
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Edit Me" exists
|
||||
await createNote(page, 'Edit Me', 'Original content');
|
||||
|
||||
// When the user opens the note and changes the title
|
||||
await noteCard(page, 'Edit Me').click();
|
||||
await page.getByTestId('note-title-input').clear();
|
||||
await page.getByTestId('note-title-input').fill('Edited Title');
|
||||
|
||||
// Then the updated title is auto-saved after the debounce delay
|
||||
// Wait for the API call that confirms the save
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// And the note card reflects the change while editor is still open
|
||||
await expect(noteCard(page, 'Edited Title')).toBeVisible();
|
||||
|
||||
// Cleanup: close the editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given the current number of notes
|
||||
const noteCards = page.locator('[data-testid="note-card"]');
|
||||
const countBefore = await noteCards.count();
|
||||
|
||||
// When the user opens a new note but leaves it empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// And waits longer than the auto-save delay
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// And closes the empty editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then no new note was created
|
||||
const countAfter = await noteCards.count();
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note and waits for auto-save
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Content that survives');
|
||||
|
||||
// Wait for auto-save to fire
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// When the page is reloaded without closing the editor
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the note is still visible
|
||||
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
|
||||
});
|
||||
});
|
||||
+196
-35
@@ -1,13 +1,20 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Toggle checklist mode via the overflow menu. */
|
||||
async function toggleChecklistMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
}
|
||||
|
||||
/** Create a checklist note via UI. Leaves the editor closed. */
|
||||
async function createChecklistNote(page: Page, title: string, items: string[]) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
|
||||
await page.getByTestId('checklist-input').nth(i).focus();
|
||||
await page.keyboard.type(items[i]);
|
||||
if (i < items.length - 1) {
|
||||
await page.getByTestId('checklist-input').nth(i).press('Enter');
|
||||
}
|
||||
@@ -22,7 +29,7 @@ test.describe('Checklist', () => {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// When the user enables checklist mode
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// Then the checklist component is displayed
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
@@ -40,7 +47,7 @@ test.describe('Checklist', () => {
|
||||
|
||||
// Then the checklist is displayed with the saved item
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
|
||||
@@ -60,11 +67,37 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: Sequential check and uncheck both persist correctly', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Tasks2', ['Buy milk']);
|
||||
|
||||
// When the user checks the item and closes
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the checked state persists
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
|
||||
// When the user unchecks the item from the done section and closes
|
||||
await page.getByTestId('checklist-done-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the unchecked state also persists (not reverted by 3-way merge)
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with one item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First item');
|
||||
|
||||
// When the user presses Enter
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
@@ -96,14 +129,15 @@ test.describe('Checklist', () => {
|
||||
// Then focus moves to the newly created empty item
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toHaveValue('');
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
|
||||
});
|
||||
|
||||
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two items where the second is empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First item');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
|
||||
@@ -118,17 +152,19 @@ test.describe('Checklist', () => {
|
||||
// Given a checklist note with two items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Pending task');
|
||||
|
||||
// When the user completes the first item
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then only the pending item remains in the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
|
||||
|
||||
// And the done section shows the completed item
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
@@ -139,12 +175,15 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with three items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Second');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Second');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(2).fill('Third');
|
||||
await page.getByTestId('checklist-input').nth(2).focus();
|
||||
await page.keyboard.type('Third');
|
||||
|
||||
// When the user presses ArrowUp from the third item
|
||||
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
|
||||
@@ -162,10 +201,12 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two top-level items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Parent');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Parent');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Child');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Child');
|
||||
|
||||
// When the user presses Tab on the second item
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
@@ -183,13 +224,16 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and two children
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Buy groceries');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Buy groceries');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(2).fill('Eggs');
|
||||
await page.getByTestId('checklist-input').nth(2).focus();
|
||||
await page.keyboard.type('Eggs');
|
||||
|
||||
// When the user checks the parent
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
@@ -202,10 +246,12 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and a child
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Groceries');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Groceries');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
|
||||
// When the user checks only the child
|
||||
@@ -220,10 +266,12 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and child, all checked
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Shopping');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Shopping');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
@@ -256,10 +304,12 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and a child
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Parent');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Parent');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Child 1');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Child 1');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
|
||||
// When the user presses Enter on the child
|
||||
@@ -285,14 +335,125 @@ test.describe('Checklist', () => {
|
||||
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
|
||||
// Given checklist mode is enabled on a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
|
||||
// When the user disables checklist mode
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// Then the rich text editor is displayed again
|
||||
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two checked and one unchecked item
|
||||
await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']);
|
||||
await noteCard(page, 'Cleanup').click();
|
||||
await page.getByTestId('checklist-checkbox').nth(1).click();
|
||||
await page.getByTestId('checklist-checkbox').nth(1).click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
|
||||
// When the user deletes checked items via the overflow menu
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('delete-checked-btn').click();
|
||||
|
||||
// Then only the unchecked item remains
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
|
||||
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with checked items
|
||||
await createChecklistNote(page, 'Reset', ['Item A', 'Item B']);
|
||||
await noteCard(page, 'Reset').click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
|
||||
// When the user unchecks all via the overflow menu
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('uncheck-all-btn').click();
|
||||
|
||||
// Then all items are back in the active section
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
|
||||
// Given a new checklist note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// When the user types a URL and leaves the field
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('check https://example.com today');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
|
||||
// Then the URL is rendered as a link
|
||||
const link = page.getByTestId('checklist-input').first().locator('a');
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
|
||||
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist item with a URL
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
|
||||
// When the user clicks the link
|
||||
await page.getByTestId('checklist-input').first().locator('a').click();
|
||||
|
||||
// Then the link popover appears with an Open button
|
||||
await expect(page.getByTestId('link-popover')).toBeVisible();
|
||||
await expect(page.getByTestId('link-popover-open')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
|
||||
// Given a link popover is open
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
await page.getByTestId('checklist-input').first().locator('a').click();
|
||||
await expect(page.getByTestId('link-popover')).toBeVisible();
|
||||
|
||||
// When the user presses Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Then the popover is dismissed
|
||||
await expect(page.getByTestId('link-popover')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist item with a URL is checked
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the done section shows the URL as a link
|
||||
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a URL
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Link Note');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('visit https://example.com');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the NoteCard preview shows the URL as a link
|
||||
const card = noteCard(page, 'Link Note');
|
||||
await expect(card.locator('a.checklist-link')).toBeVisible();
|
||||
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,17 @@ async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle markdown mode via the overflow menu. */
|
||||
async function toggleMarkdownMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
}
|
||||
|
||||
/** Type content into the TipTap editor via markdown mode (reliable for e2e) */
|
||||
async function typeViaMarkdown(page: Page, content: string) {
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
await page.getByTestId('note-content-input').fill(content);
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
}
|
||||
|
||||
test.describe('Rich text formatting', () => {
|
||||
@@ -34,7 +40,7 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
|
||||
// And the markdown content contains bold syntax
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
|
||||
});
|
||||
|
||||
@@ -95,7 +101,7 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(editor.locator('strong')).toHaveText('bold');
|
||||
|
||||
// When the user toggles markdown mode
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
|
||||
// Then the raw markdown textarea is shown with the markdown source
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const TEST_DB = './data/test-crumbs.db';
|
||||
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
|
||||
|
||||
export default function globalSetup() {
|
||||
// Clean test database before E2E run so each run starts fresh
|
||||
@@ -10,4 +12,14 @@ export default function globalSetup() {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random passwords for this test run, shared across all workers
|
||||
writeFileSync(
|
||||
TEST_CREDENTIALS_FILE,
|
||||
JSON.stringify({
|
||||
testPassword: `test-${randomUUID()}`,
|
||||
collabPassword: `collab-${randomUUID()}`,
|
||||
userPassword: `user-${randomUUID()}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { collectCoverage } from './coverage';
|
||||
import { TEST_CREDENTIALS_FILE } from '../global-setup';
|
||||
|
||||
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
const TEST_EMAIL = 'admin@test.com';
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
const TEST_PASSWORD = testPassword;
|
||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_PASSWORD = collabPassword;
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
|
||||
+1
-1
@@ -292,7 +292,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.25.0</span>
|
||||
<span class="footer-version">v0.28.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user