Files
thoughtsync/frontend/src/components/NoteEditor.vue
T
bvandeusenandClaude Opus 4.8 2d72dcc7cb
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 38s
M3 wiki-links: [[links]] + backlinks
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
  create/update and rewrite the source's links. GET /api/notes/titles (owner
  {id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
  shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
  clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.

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

348 lines
12 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 { api } from "../api/client";
import { useNotesStore } from "../stores/notes";
import { useTitlesStore } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import NoteChecklist from "./NoteChecklist.vue";
import type { Note, NoteLabel } from "../stores/notes";
import type { NoteColor } from "../notes/colors";
const props = defineProps<{ note: Note }>();
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const titles = useTitlesStore();
// Read the note reactively from the store so checklist item add/toggle/delete
// (which reconcile a fresh note object) reflect live while the editor is open.
const liveNote = computed(() => notes.items.find((n) => n.id === props.note.id) ?? props.note);
const title = ref(props.note.title ?? "");
const body = ref(props.note.body);
const color = ref<NoteColor>(props.note.color);
const labelList = ref<NoteLabel[]>([...props.note.labels]);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
watch(
() => props.note,
(n) => {
title.value = n.title ?? "";
body.value = n.body;
color.value = n.color;
labelList.value = [...n.labels];
},
);
const backlinks = ref<{ id: string; title: string }[]>([]);
async function loadBacklinks() {
try {
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(
`/api/notes/${props.note.id}/backlinks`,
);
backlinks.value = res.backlinks;
} catch {
backlinks.value = [];
}
}
onMounted(async () => {
void titles.load();
void loadBacklinks();
await nextTick();
bodyInput.value?.focus();
});
watch(
() => props.note.id,
() => void loadBacklinks(),
);
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 title = match[1].trim();
const key = title.toLowerCase();
if (title && !seen.has(key)) {
seen.add(key);
out.push({ title, id: titles.resolve(title)?.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);
}
async function onLabelsChange(next: NoteLabel[]) {
labelList.value = next;
await notes.setLabels(
props.note.id,
next.map((lb) => lb.id),
);
}
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
async function toggleKind() {
if (liveNote.value.kind === "list") {
await notes.setKind(props.note.id, "text");
return;
}
// Convert existing body lines into checklist items, then switch to a list.
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(props.note.id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(props.note.id, { title: title.value, body: "", color: color.value });
}
await notes.setKind(props.note.id, "list");
}
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
function pickImage() {
fileInput.value?.click();
}
async function uploadFile(file: File) {
uploadError.value = "";
try {
await notes.uploadAttachment(props.note.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);
}
}
async function close() {
const changed =
(title.value.trim() || null) !== (props.note.title ?? null) ||
body.value !== props.note.body ||
color.value !== props.note.color;
if (changed) {
await notes.saveEdit(props.note.id, { title: title.value, body: body.value, color: color.value });
}
emit("close");
}
async function act(fn: () => Promise<void>) {
await fn();
emit("close");
}
</script>
<template>
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
@mousedown.self="close"
>
<div
class="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="close"
@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="" 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(note.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"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
/>
<textarea
v-if="liveNote.kind === 'text'"
ref="bodyInput"
v-model="body"
rows="8"
placeholder="Take a note…"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
/>
<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 bg-black/5 px-2 py-0.5 text-xs text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
{{ lb.name }}
<button
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="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>
<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="!note.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="!note.trashed"
type="button"
class="icon-btn"
:class="liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
:title="liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
@click="toggleKind"
>
<Icon name="checkbox" />
</button>
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
<template v-if="!note.trashed">
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
@click="act(() => notes.setPinned(note.id, !note.pinned))"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
@click="act(() => notes.setArchived(note.id, !note.archived))"
>
<Icon name="archive" />
</button>
<button type="button" class="icon-btn" title="Move to trash" @click="act(() => notes.trash(note.id))">
<Icon name="trash" />
</button>
</template>
<template v-else>
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(note.id))">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
@click="act(() => notes.deleteForever(note.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 dark:text-neutral-200 dark:hover:bg-neutral-800"
@click="close"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>