- 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
348 lines
12 KiB
Vue
348 lines
12 KiB
Vue
<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>
|