A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'. Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1. Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision. Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
807 lines
28 KiB
Vue
807 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { api } from "../api/client";
|
||
import { useNotesStore } from "../stores/notes";
|
||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
||
import ColorPicker from "./ColorPicker.vue";
|
||
import Icon from "./Icon.vue";
|
||
import LabelPicker from "./LabelPicker.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 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,
|
||
labels: labelList.value,
|
||
items: [],
|
||
attachments: [],
|
||
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));
|
||
}
|
||
|
||
// ---- 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 pickImage() {
|
||
fileInput.value?.click();
|
||
}
|
||
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 image.";
|
||
}
|
||
}
|
||
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 gap-2">
|
||
<div v-for="att in liveNote.attachments" :key="att.id" 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 image"
|
||
@click="notes.deleteAttachment(liveNote.id, att.id)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</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="!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="Add image"
|
||
aria-label="Add image"
|
||
@click="pickImage"
|
||
>
|
||
<Icon name="image" />
|
||
</button>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||
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>
|