CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
756 lines
28 KiB
Vue
756 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||
import { useNotesStore } from "../stores/notes";
|
||
import { useConfigStore } from "../stores/config";
|
||
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 { takeMorphOrigin } from "../composables/useEditorMorph";
|
||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||
|
||
// 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 config = useConfigStore();
|
||
|
||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||
const title = ref(props.note?.title ?? "");
|
||
const body = ref(props.note?.body ?? props.initialBody);
|
||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||
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 checklistOpen = ref(false);
|
||
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("");
|
||
|
||
// 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/remind_at uniformly.
|
||
const draftNote = computed<Note>(() => ({
|
||
id: "",
|
||
title: title.value.trim() || null,
|
||
display_title: "",
|
||
body: body.value,
|
||
color: color.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,
|
||
);
|
||
// The checklist renders once the note has items, or once someone has asked for one.
|
||
// It sits BELOW the body rather than instead of it — a note can carry both, which is
|
||
// the whole point of the merge.
|
||
//
|
||
// Items need a persisted note to hang off, so this is a rich action like attaching a
|
||
// file: in compose it waits for the draft to be saved.
|
||
const showChecklist = computed(
|
||
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.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;
|
||
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> {
|
||
const 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 = 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 = [];
|
||
checklistOpen.value = false;
|
||
baseline.value = { title: null, body: "", color: "default" };
|
||
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();
|
||
bodyInput.value?.focus();
|
||
}
|
||
// ---- 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();
|
||
const el = bodyInput.value;
|
||
el?.focus();
|
||
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
|
||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||
});
|
||
|
||
function onBodyKeydown(e: KeyboardEvent) {
|
||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||
e.preventDefault();
|
||
void commitAndContinue();
|
||
return;
|
||
}
|
||
}
|
||
|
||
function onTitleEnter(e: KeyboardEvent) {
|
||
e.preventDefault();
|
||
if (e.shiftKey && isCreate.value) 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;
|
||
}
|
||
|
||
// ---- add a checklist ----
|
||
//
|
||
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
|
||
// body and gains a place to put items. Persists the draft first for the same reason
|
||
// attaching a file does — an item needs a note to belong to.
|
||
async function addChecklist() {
|
||
if (checklistOpen.value) return;
|
||
const id = await ensureDraft();
|
||
if (!id) return;
|
||
checklistOpen.value = true;
|
||
}
|
||
|
||
// ---- 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();
|
||
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);
|
||
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;
|
||
}
|
||
</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>
|
||
|
||
<!-- 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"
|
||
/>
|
||
|
||
<textarea
|
||
ref="bodyInput"
|
||
v-model="body"
|
||
rows="8"
|
||
:placeholder="bodyPlaceholder"
|
||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||
@keydown="onBodyKeydown"
|
||
/>
|
||
<!-- Below the body, not instead of it. -->
|
||
<NoteChecklist
|
||
v-if="showChecklist"
|
||
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>
|
||
</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>
|
||
|
||
<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="richEnabled && !liveNote.trashed && !showChecklist"
|
||
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>
|
||
</div>
|
||
</Transition>
|
||
</template>
|