Files
thoughtsync/frontend/src/components/NoteEditor.vue
T
bvandeusenandClaude Opus 4.8 d1bc91a54a
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 29s
M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
Completes 1908 without Web Push. While the app is open, due reminders now
actually surface; recurring reminders + snooze/done are manageable.

- reminders store (singleton): polls /api/notes/reminders every 45s while
  the app is open; each due reminder fires ONCE as a toast (with an "Open"
  action) and, if the user opts in, a page-context OS Notification — no
  service worker, no PWA. Silently primes a stale backlog on first load;
  only announces recently-due ones. AppShell starts/stops it.
- Editor reminder section: a Repeat picker (Does not repeat / Daily /
  Weekly / Monthly / Yearly) + Done (advances a recurring reminder / clears
  a one-off) + Snooze 1h/1d, shown when a reminder is set.
- RemindersView rebuilt as a chronological list: per row a due time +
  recurrence badge + Done / 1h / 1d, click to open; plus an "Enable
  notifications" opt-in and a note that background alerts come with native.
- Note type gains `recurrence`; notes store setRecurrence /
  completeReminder / snoozeReminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 11:58:44 -04:00

957 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
import { useTitlesStore, type TitleEntry } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
// One editor for BOTH composing and editing. `inline` renders the board composer
// frame (collapsible, in-flow); the default renders the modal editor. `note` = the
// note being edited, or null to compose a new one. In compose mode there is no note
// id until a draft is persisted — on commit-with-content, or on the first rich action
// (label / image / reminder / checklist) — so we never litter empty notes.
const props = withDefaults(defineProps<{ note?: Note | null; inline?: boolean; autofocus?: boolean }>(), {
note: null,
inline: false,
autofocus: false,
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const config = useConfigStore();
const titles = useTitlesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? "");
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
const expanded = ref(!props.inline); // modal is always open; inline starts collapsed
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
const backlinks = ref<{ id: string; title: string }[]>([]);
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
title: props.note?.title ?? null,
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
// Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed(() => !isCreate.value || hasContent.value);
// A synthetic note for compose mode (before anything is persisted), so the shared
// template can read attachments/items/kind/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
title: title.value.trim() || null,
display_title: "",
body: body.value,
color: color.value,
kind: createKind.value,
position: 0,
pinned: false,
archived: false,
trashed: false,
remind_at: null,
recurrence: null,
labels: labelList.value,
items: [],
attachments: [],
previews: [],
created_at: null,
updated_at: null,
}));
const liveNote = computed<Note>(() =>
noteId.value
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// Only edit-mode list notes render the interactive checklist; compose-list types
// lines into the textarea (they become items on create).
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
const bodyPlaceholder = computed(() =>
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
);
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
(n) => {
noteId.value = n?.id ?? null;
title.value = n?.title ?? "";
body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
let created: Note;
if (createKind.value === "list") {
const items = body.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
body.value = ""; // the lines moved into checklist items
} else {
created = await notes.create({ title: title.value, body: body.value, color: color.value });
}
noteId.value = created.id;
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
// null when there's nothing to create yet (empty compose — callers just no-op).
async function ensureDraft(): Promise<string | null> {
if (noteId.value) return noteId.value;
if (!hasContent.value) return null;
await createFromFields();
return noteId.value;
}
// Persist current text fields: create in compose, patch in edit.
async function flush(): Promise<void> {
if (saving.value) return;
if (isCreate.value) {
if (!hasContent.value) return;
saving.value = true;
try {
await createFromFields();
} finally {
saving.value = false;
}
return;
}
const b = baseline.value;
const nextBody = showChecklist.value ? b.body : body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
} finally {
saving.value = false;
}
}
function resetCompose(): void {
noteId.value = null;
title.value = "";
body.value = "";
color.value = "default";
labelList.value = [];
createKind.value = "text";
baseline.value = { title: null, body: "", color: "default" };
linkMenu.value = false;
uploadError.value = "";
}
// ---- inline (compose) frame ----
async function open(): Promise<void> {
expanded.value = true;
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
async function commitInline(): Promise<void> {
await flush();
resetCompose();
expanded.value = false;
}
async function commitAndContinue(): Promise<void> {
if (isCreate.value && !hasContent.value) return;
await flush();
resetCompose();
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
// ---- modal (edit) frame ----
async function close(): Promise<void> {
await flush();
emit("close");
}
function onEsc(): void {
if (props.inline) void commitInline();
else void close();
}
function onMetaEnter(): void {
// Ctrl/Cmd+Enter = finish & close, in both frames (matches email/chat "send").
if (props.inline) void commitInline();
else void close();
}
function onBackdropMousedown(): void {
if (!props.inline) void close();
}
function onDocClick(e: MouseEvent): void {
// Commit the inline composer on an OUTSIDE *click* (bubble phase, not mousedown):
// the clicked target's own handler fires first, so clicking another card's toolbar
// performs its action, THEN the composer collapses — no wasted first click.
if (props.inline && expanded.value && root.value && !root.value.contains(e.target as Node)) {
void commitInline();
}
}
// Grow the composer textarea to fit its content (inline only; modal uses fixed rows).
function autoGrow(): void {
if (!props.inline) return;
const el = bodyInput.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
async function loadBacklinks(): Promise<void> {
if (!noteId.value) {
backlinks.value = [];
return;
}
try {
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(`/api/notes/${noteId.value}/backlinks`);
backlinks.value = res.backlinks;
} catch {
backlinks.value = [];
}
}
watch(() => noteId.value, loadBacklinks);
onMounted(async () => {
void titles.load();
void loadBacklinks();
if (props.inline) {
document.addEventListener("click", onDocClick);
if (props.autofocus) void open();
} else {
await nextTick();
bodyInput.value?.focus();
}
});
onBeforeUnmount(() => document.removeEventListener("click", onDocClick));
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed(() => {
const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>();
const out: { title: string; id: string | null }[] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body.value)) !== null) {
const t = match[1].trim();
const key = t.toLowerCase();
if (t && !seen.has(key)) {
seen.add(key);
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
}
}
return out;
});
async function openLink(link: { title: string; id: string | null }) {
if (link.id) {
emit("navigate", link.id);
return;
}
const created = await notes.createTitled(link.title);
await titles.reload();
emit("navigate", created.id);
}
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref(false);
const linkQuery = ref("");
const linkStart = ref(-1);
const linkSelected = ref(0);
const linkMatches = ref<TitleEntry[]>([]);
let linkTimer: ReturnType<typeof setTimeout> | undefined;
function refreshLinkMatches() {
if (linkTimer) clearTimeout(linkTimer);
const q = linkQuery.value.trim();
linkTimer = setTimeout(async () => {
try {
const res = await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`);
linkMatches.value = res.results.filter((r) => r.id !== noteId.value).slice(0, 8);
} catch {
linkMatches.value = [];
}
linkSelected.value = 0;
}, 120);
}
function onBodyInput() {
if (props.inline) autoGrow();
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
const text = body.value.slice(0, caret);
const open = text.lastIndexOf("[[");
if (open === -1) {
linkMenu.value = false;
return;
}
const between = text.slice(open + 2);
if (between.includes("]") || between.includes("\n")) {
linkMenu.value = false;
return;
}
linkQuery.value = between;
linkStart.value = open;
linkSelected.value = 0;
linkMenu.value = true;
refreshLinkMatches();
}
function insertLink(t: string) {
const el = bodyInput.value;
const caret = el?.selectionStart ?? body.value.length;
const before = body.value.slice(0, linkStart.value);
const after = body.value.slice(caret);
const insertion = `[[${t}]]`;
body.value = before + insertion + after;
linkMenu.value = false;
const pos = before.length + insertion.length;
void nextTick(() => {
el?.focus();
el?.setSelectionRange(pos, pos);
});
}
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (props.inline && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
return;
}
if (!linkMenu.value || linkMatches.value.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
linkSelected.value = Math.max(linkSelected.value - 1, 0);
} else if (e.key === "Enter" || e.key === "Tab") {
const m = linkMatches.value[linkSelected.value];
if (m) {
e.preventDefault();
insertLink(m.title);
}
} else if (e.key === "Escape") {
// Close only the menu — don't let Esc bubble to the frame's close/commit.
e.preventDefault();
e.stopPropagation();
linkMenu.value = false;
}
}
function onTitleEnter(e: KeyboardEvent) {
if (!props.inline) return;
e.preventDefault();
if (e.shiftKey) void commitAndContinue();
else bodyInput.value?.focus();
}
// ---- reminder ----
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
async function onReminderChange(e: Event) {
const id = await ensureDraft();
if (!id) return;
await notes.setReminder(id, fromLocalInput((e.target as HTMLInputElement).value));
}
async function onRecurrenceChange(e: Event) {
const id = await ensureDraft();
if (!id) return;
await notes.setRecurrence(id, (e.target as HTMLSelectElement).value || null);
}
async function completeReminder() {
if (noteId.value) await notes.completeReminder(noteId.value);
}
async function snoozeReminder(minutes: number) {
if (noteId.value) await notes.snoozeReminder(noteId.value, minutes);
}
// ---- labels ----
async function onLabelsChange(next: NoteLabel[]) {
const id = await ensureDraft();
if (!id) return;
// Tag-sourced labels are governed by the note body, not the picker — always keep
// them so a picker save can't strip a label the #tag still mandates.
const tagLabels = labelList.value.filter((lb) => lb.via_tag);
const tagIds = new Set(tagLabels.map((lb) => lb.id));
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
labelList.value = merged;
await notes.setLabels(
id,
merged.map((lb) => lb.id),
);
}
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
async function toggleKind() {
if (isCreate.value) {
createKind.value = createKind.value === "list" ? "text" : "list";
bodyInput.value?.focus();
void nextTick(autoGrow);
return;
}
const id = noteId.value as string;
if (liveNote.value.kind === "list") {
await notes.setKind(id, "text");
return;
}
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
}
await notes.setKind(id, "list");
}
// ---- attachments ----
function pickFile() {
fileInput.value?.click();
}
function attKind(mime: string): "image" | "audio" | "file" {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
return "file";
}
function fmtSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function uploadFile(file: File) {
const id = await ensureDraft();
if (!id) return;
uploadError.value = "";
try {
await notes.uploadAttachment(id, file);
} catch (e) {
uploadError.value = (e as { error?: string }).error ?? "Could not upload file.";
}
}
// ---- link previews (URL unfurl) ----
const unfurling = ref<string | null>(null); // the URL currently being fetched
const unfurlError = ref("");
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
const detectedUrls = computed(() => {
const out: string[] = [];
for (const m of body.value.matchAll(URL_RE)) {
const u = m[1].replace(/[.,;:!?]+$/, "");
if (!out.includes(u)) out.push(u);
}
return out;
});
const previewedUrls = computed(() => new Set(liveNote.value.previews.map((p) => p.url)));
const unpreviewedUrls = computed(() => detectedUrls.value.filter((u) => !previewedUrls.value.has(u)));
async function addPreview(url: string) {
const id = await ensureDraft();
if (!id) return;
unfurling.value = url;
unfurlError.value = "";
try {
await notes.unfurl(id, url);
} catch (e) {
unfurlError.value = (e as { error?: string }).error ?? "Couldn't fetch a preview for that link.";
} finally {
unfurling.value = null;
}
}
function shortUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) await uploadFile(file);
input.value = "";
}
async function onPaste(e: ClipboardEvent) {
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile();
if (file) {
e.preventDefault();
await uploadFile(file);
}
}
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
async function act(fn: () => Promise<void>) {
await fn();
emit("close");
}
// ---- version history (modal edit only) ----
const showHistory = ref(false);
const revisions = ref<NoteRevision[]>([]);
async function loadRevisions() {
if (!noteId.value) {
revisions.value = [];
return;
}
try {
revisions.value = await notes.fetchRevisions(noteId.value);
} catch {
revisions.value = [];
}
}
function toggleHistory() {
showHistory.value = !showHistory.value;
if (showHistory.value) void loadRevisions();
}
async function restoreRevisionAt(revId: string) {
const id = noteId.value;
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
title.value = updated.title ?? "";
body.value = updated.body;
color.value = updated.color;
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
if (!iso) return "";
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function revPreview(rev: NoteRevision): string {
const t = (rev.title ?? "").trim();
const b = rev.body.trim().replace(/\s+/g, " ");
const s = t && b ? `${t}${b}` : t || b;
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
}
defineExpose({ open });
</script>
<template>
<div
ref="root"
:class="
inline
? 'mx-auto w-full max-w-xl'
: 'fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]'
"
@mousedown.self="onBackdropMousedown"
>
<!-- Collapsed composer (inline, board) -->
<div
v-if="inline && !expanded"
class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<button
type="button"
class="w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
@click="open"
>
Take a note
</button>
</div>
<!-- Shared editor card: compose (expanded, inline) OR edit (modal) -->
<div
v-else
:class="
inline
? 'w-full rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900'
: 'w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900'
"
:role="inline ? undefined : 'dialog'"
:aria-modal="inline ? undefined : 'true'"
@keydown.esc.stop="onEsc"
@keydown.enter.meta.prevent="onMetaEnter"
@keydown.enter.ctrl.prevent="onMetaEnter"
@paste="onPaste"
>
<div class="flex flex-col gap-2 p-4">
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
<template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button
type="button"
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove attachment"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</div>
<!-- Audio inline player -->
<div
v-else-if="attKind(att.mime) === 'audio'"
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-2 py-1 dark:border-neutral-700"
>
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
<button
type="button"
class="text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</div>
<!-- Any other file download chip -->
<a
v-else
:href="att.url"
download
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 text-xs hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Icon name="paperclip" />
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
<button
type="button"
class="ml-1 text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@click.prevent.stop="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</a>
</template>
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<!-- Link previews: stored preview cards + one "Preview <domain>" per detected URL -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
:key="p.id"
:preview="p"
:removable="!liveNote.trashed"
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
<div
v-if="config.enableUrlUnfurl && !liveNote.trashed && unpreviewedUrls.length"
class="flex flex-wrap gap-1.5"
>
<button
v-for="u in unpreviewedUrls"
:key="u"
type="button"
class="inline-flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-0.5 text-xs text-neutral-500 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
:disabled="unfurling === u"
@click="addPreview(u)"
>
<Icon name="link" />
{{ unfurling === u ? "Fetching" : `Preview ${shortUrl(u)}` }}
</button>
</div>
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter="onTitleEnter"
/>
<div v-if="!showChecklist" class="relative">
<textarea
ref="bodyInput"
v-model="body"
:rows="inline ? undefined : 8"
:placeholder="bodyPlaceholder"
:class="
inline
? 'max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
: 'w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
<ul
v-if="linkMenu && linkMatches.length"
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<li v-for="(m, i) in linkMatches" :key="m.id">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
:class="
i === linkSelected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
"
@mousemove="linkSelected = i"
@mousedown.prevent="insertLink(m.title)"
>
<span class="truncate">{{ m.title }}</span>
</button>
</li>
</ul>
</div>
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
v-for="lb in labelList"
:key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>
{{ lb.via_tag ? "#" + lb.name : lb.name }}
<button
v-if="!lb.via_tag"
type="button"
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
:aria-label="`Remove ${lb.name}`"
@click="removeLabel(lb.id)"
>
×
</button>
</span>
</div>
<div v-if="richEnabled" class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" />
<input
type="datetime-local"
:value="reminderLocal"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:[color-scheme:dark]"
@change="onReminderChange"
/>
<button
v-if="liveNote.remind_at"
type="button"
class="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
@click="notes.setReminder(liveNote.id, null)"
>
Clear
</button>
</div>
<div v-if="richEnabled && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<label class="text-neutral-400">Repeat</label>
<select
:value="liveNote.recurrence ?? ''"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
@change="onRecurrenceChange"
>
<option value="">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<button
type="button"
class="rounded-md border border-neutral-300 px-2 py-1 text-neutral-600 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
@click="completeReminder"
>
Done
</button>
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(60)">
Snooze 1h
</button>
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(1440)">
1d
</button>
</div>
<div
v-if="!inline && !isCreate && (outgoingLinks.length || backlinks.length)"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<div v-if="outgoingLinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="link in outgoingLinks"
:key="link.title"
type="button"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
link.id
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
"
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
@click="openLink(link)"
>
{{ link.title }}<span v-if="!link.id" class="opacity-60"></span>
</button>
</div>
</div>
<div v-if="backlinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in backlinks"
:key="b.id"
type="button"
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
@click="emit('navigate', b.id)"
>
{{ b.title }}
</button>
</div>
</div>
</div>
<div
v-if="!inline && !isCreate && showHistory"
class="flex flex-col gap-1 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">History</p>
<p v-if="!revisions.length" class="text-xs text-neutral-400">
No earlier versions yet — your edits will show up here.
</p>
<ul v-else class="flex max-h-40 flex-col gap-0.5 overflow-y-auto">
<li v-for="rev in revisions" :key="rev.id" class="flex items-center gap-2 rounded-md px-1 py-1 text-xs">
<span class="shrink-0 tabular-nums text-neutral-400">{{ revLabel(rev.created_at) }}</span>
<span class="min-w-0 flex-1 truncate text-neutral-600 dark:text-neutral-300">{{ revPreview(rev) }}</span>
<button
type="button"
class="shrink-0 rounded px-1.5 py-0.5 font-medium text-brand-700 hover:bg-brand/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-brand"
@click="restoreRevisionAt(rev.id)"
>
Restore
</button>
</li>
</ul>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
type="button"
class="icon-btn"
title="Attach a file"
aria-label="Attach a file"
@click="pickFile"
>
<Icon name="paperclip" />
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
type="button"
class="icon-btn"
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed="isListMode"
@click="toggleKind"
>
<Icon name="checkbox" />
</button>
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="!inline && !isCreate"
type="button"
class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
title="Version history"
aria-label="Version history"
:aria-pressed="showHistory"
@click="toggleHistory"
>
<Icon name="history" />
</button>
<template v-if="!inline && !isCreate && !liveNote.trashed">
<button
type="button"
class="icon-btn"
:class="liveNote.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="liveNote.pinned ? 'Unpin' : 'Pin'"
@click="act(() => notes.setPinned(liveNote.id, !liveNote.pinned))"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="liveNote.archived ? 'Unarchive' : 'Archive'"
@click="act(() => notes.setArchived(liveNote.id, !liveNote.archived))"
>
<Icon name="archive" />
</button>
<button
type="button"
class="icon-btn"
title="Move to trash"
@click="act(() => notes.trash(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<template v-else-if="!inline && !isCreate && liveNote.trashed">
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
@click="act(() => notes.deleteForever(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="saving"
@click="inline ? commitInline() : close()"
>
{{ inline ? "Done" : "Close" }}
</button>
</div>
</div>
</div>
</div>
</template>