Files
thoughtsync/frontend/src/components/NoteEditor.vue
T
bvandeusenandClaude Opus 5 fa89da1fab
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
notes: color leaves the model, the wire and all three surfaces
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of
this field: a card is one neutral surface per theme, and the only coloured
thing on a board is a tag. What was left was a column written by a picker and
read by nothing.

Rule 22 — the old path comes out completely. No flag, no fallback, no
"override if set".

Server: the column, the `?color=` facet, the create/update/serialise paths,
the sync assignment, the front-matter line, and Keep's colour map. Alembic
0029 drops it and sweeps `"color"` out of stored saved-filter params — a view
that silently filtered on a field the app no longer has would return nothing
and never say why. That sweep is Python, not `params::jsonb - 'color'`,
because Postgres has no try-cast and one malformed blob would abort a
migration that is running over somebody's saved views.

`NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on
the model that lost one is an invitation to put the column back; labels still
name a colour, so the vocabulary belongs where the normalizer already is.

Core: the field, the facet, the `NoteCreateInput`, and every read and write in
store/push/pull. Local schema v9 drops the column and does the same
saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key
rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and
`NoteDraft.color` with it.

Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule,
the FilterBar colour row, the facet in the query round-trip, and the colour
half of the editor's baseline-and-save. Android: the `ColorSheet`, the
`Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`.

## The protocol: v4, and the floor deliberately stays at 3

Checked against `compat.rs` and the push handler rather than trusting the
`#[serde(default)]` annotation, because the v2 precedent points the other way:
v2 dropped `kind` and `title` and DID raise both floors, on the rule that
dropping a field a client sends and expects back is breaking.

`color` fails the second half of that test. A v3 client reading a v4 note gets
`"default"` from its own serde default and draws the colour it derives
locally — the board it drew yesterday. A v3 client pushing `color` has the key
ignored, since `_assign_note_fields` reads its payload key by key and never
validates the shape. Neither direction errors and neither shows anything
wrong. `title` was the note's NAME; this is a field that no longer renders.

So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both
floors stay at 3. `docs/sync.md` carries the reasoning and the per-version
history, and its push example is brought back in line — it still listed
`title`, `kind` and `items`, all gone before this.

Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:`
imports fine, the key simply read past. Old exports must still import.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00

807 lines
31 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, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `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; initialBody?: string }>(), {
note: null,
initialBody: "",
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
// One element per block, keyed by the block's id — the only thing about a block that
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => 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/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
position: 0,
pinned: false,
archived: false,
trashed: false,
deleted_at: null,
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,
);
const bodyPlaceholder = "Take 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;
setBody(n?.body ?? "");
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "" };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value });
noteId.value = created.id;
baseline.value = { body: created.body };
}
// 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 = body.value;
const changed = nextBody !== b.body;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody };
} finally {
saving.value = false;
}
}
function resetCompose(): void {
noteId.value = null;
setBody("");
labelList.value = [];
baseline.value = { body: "" };
uploadError.value = "";
}
// ---- commit / close ----
// Compose only: save the current note and start a fresh one (rapid capture).
async function commitAndContinue(): Promise<void> {
if (!hasContent.value) return;
await flush();
resetCompose();
await nextTick();
growAll();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
}
// ---- open/close animation (M7) ----
//
// Owned here rather than by each of the five views that render this component: the
// leave has to play BEFORE the host unmounts us, so the component has to control
// its own visibility and tell the host afterwards.
// Starts TRUE, with `appear` driving the entry animation. Starting false and
// flipping it on mount would be the obvious shape, but the panel lives inside this
// v-if — it wouldn't exist yet to measure.
const visible = ref(true);
const panel = ref<HTMLElement | null>(null);
/** Matches .editor-leave-active in style.css. */
const LEAVE_MS = 140;
onMounted(() => {
const from = takeMorphOrigin();
// Grow from the card that was clicked. Set as a transform-origin rather than
// animating between two rects — see useEditorMorph for why. No origin (compose,
// or a card that scrolled out of view) simply grows from its own centre.
if (!from || !panel.value) return;
// offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has
// already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN
// panel and the origin would land a few pixels off. Offsets are layout geometry
// and ignore transforms. The offset parent is the inset-0 backdrop, so these are
// effectively viewport coordinates — which is what the captured point is in.
panel.value.style.transformOrigin =
`${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`;
});
/** Play the leave, then let the host unmount us. */
async function finish(): Promise<void> {
if (prefersReducedMotion()) {
emit("close");
return;
}
visible.value = false;
await new Promise((resolve) => setTimeout(resolve, LEAVE_MS));
emit("close");
}
// Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> {
await flush();
await finish();
}
// Esc / click-away DISMISS. A brand-new, not-yet-persisted note is DISCARDED — so an
// accidental keystroke or type-to-compose never litters the board. To keep a new note,
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
// compose already persisted by a rich action, closes normally (saving its text).
async function dismiss(): Promise<void> {
if (isCreate.value) {
await finish();
return;
}
await close();
}
function onEsc(): void {
void dismiss();
}
function onMetaEnter(): void {
// Ctrl/Cmd+Enter = finish & close, an explicit commit (matches email/chat "send").
void close();
}
function onBackdropMousedown(): void {
void dismiss();
}
onMounted(async () => {
await nextTick();
growAll();
// The LAST block, with the caret after its text: opening a note means continuing it,
// and type-to-compose seeds text that should be typed straight on from.
const last = blocks.value[blocks.value.length - 1];
await focusBlock(last ? last.id : null);
});
/** Editing one block: replace its text, leave every other block alone. */
function setText(index: number, text: string): void {
const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function setChecked(index: number, checked: boolean): void {
const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
}
}
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
removeBlock(index);
}
function removeBlock(index: number): void {
const next = withoutIndex(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.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));
}
// ---- add a checklist ----
//
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
// this one needs NO persisted note to hang anything off — a checklist is part of the
// body (M304), so it works on an empty compose box the moment it opens.
//
// Appends rather than inserting at the caret because a block editor has no single
// caret to insert at: the field that had focus may not be the one being looked at by
// the time this runs.
function addChecklist(): void {
const next = plusTask(blocks.value);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
// ---- 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 ----
//
// Nothing to trigger any more: the server unfurls a note's URLs in the background
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
// is left here is removing one you don't want — the editor is the only place with
// room to offer that, and the card deliberately doesn't.
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();
await finish();
}
// ---- 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);
setBody(updated.body);
baseline.value = { body: updated.body };
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 s = rev.body.trim().replace(/\s+/g, " ");
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
}
</script>
<template>
<!-- `appear` because we mount already-open: the host renders us with v-if, so the
enter has to fire on the first frame rather than on a later state change. -->
<Transition name="editor" appear>
<div
v-if="visible"
ref="root"
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
@mousedown.self="onBackdropMousedown"
>
<!-- One editor card for BOTH compose (empty note) and edit a single surface.
`editor-panel` is the animation's handle: the backdrop only fades, while
this scales from the card that opened it (see useEditorMorph). -->
<div
ref="panel"
class="editor-panel w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
role="dialog"
aria-modal="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="hover-reveal 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>
<!-- Fetched automatically after each save; removable here and nowhere else. -->
<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>
<!-- The body, as fields and checkboxes rather than as markup. A checklist
item is a real input; a run of prose is one textarea, so typing a
paragraph still feels like typing a paragraph. -->
<div class="flex flex-col gap-1">
<template v-for="(block, i) in blocks" :key="block.id">
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
<input
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea
v-else
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@blur="onProseBlur(i)"
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<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="labelChipClasses(lb)"
>
<!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ 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>
<div
v-if="!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>
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<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"
title="Add a checklist"
aria-label="Add a checklist"
@click="addChecklist"
>
<Icon name="checkbox" />
</button>
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="!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="!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="!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="
isCreate
? 'rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60'
: '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="close()"
>
{{ isCreate ? "Add note" : "Close" }}
</button>
</div>
</div>
</div>
</Transition>
</template>