Files
inkwell/frontend/src/components/NoteCard.vue
T
bvandeusen 982d24c83b
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 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
links: bind a [[link]] to a note, not to a string
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
2026-08-22 11:02:39 -04:00

435 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_CARD_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
type NoteColor,
} from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import {
cardIdAt,
draggingId,
DRAG_THRESHOLD_PX,
endCardDrag,
overId,
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
(e: "dragend", note: Note): void;
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
* for it, so an id is all it can know without looking the note up again. */
(e: "drop", targetId: string): void;
/** A reminder was completed or snoozed. The store reconciles itself, so the board
* needs nothing — this is for hosts holding their OWN list (the reminders lens
* fetches separately) that must re-query to drop the note they just cleared. */
(e: "reminder-changed"): void;
}>();
const notes = useNotesStore();
const config = useConfigStore();
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it.
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
const trashDays = computed(() =>
props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
);
const trashCountdown = computed(() => formatTrashCountdown(trashDays.value));
// Same red the overdue reminder uses — the last few days are worth noticing.
const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3);
// The card previews the first image inline; non-image files show as compact chips.
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
// plain tap/click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land.
//
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed;
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);
/** The pointer that owns the current gesture; null when no press is in flight. */
let activePointer: number | null = null;
let startX = 0;
let startY = 0;
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
let moved = false;
function onGripDown(e: PointerEvent) {
if (!canDrag()) return;
activePointer = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
// Capture so the gesture keeps reporting to this grip even once the finger has
// travelled onto another card — without it the stream stops at the first
// boundary crossing.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Claims the gesture from the browser's own scroll/text-selection handling.
e.preventDefault();
}
function onGripMove(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
if (!moved) {
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
moved = true;
draggingId.value = props.note.id;
emit("dragstart", props.note);
}
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
}
function onGripUp(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const target = moved ? overId.value : null;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (target) emit("drop", target);
// Always paired with dragstart, dropped or not, so the parent can clear its own
// tracked source rather than leaking it into the next gesture.
if (wasDragging) emit("dragend", props.note);
}
// Fires when the system takes the gesture away (an incoming call, a browser-level
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
function onGripCancel(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (wasDragging) emit("dragend", props.note);
}
// When this card becomes the keyboard-focused card, scroll it into view.
watch(
() => props.active,
(a) => {
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
);
async function completeReminder(): Promise<void> {
await notes.completeReminder(props.note.id);
emit("reminder-changed");
}
async function snoozeReminder(minutes: number): Promise<void> {
await notes.snoozeReminder(props.note.id, minutes);
emit("reminder-changed");
}
function cardClass(color: NoteColor): string {
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
}
function labelChip(color: string): string {
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="[
cardClass(note.color),
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
: '',
active ? 'ring-2 ring-brand' : '',
]"
:data-note-id="note.id"
>
<img
v-if="firstImage"
:src="firstImage.url"
alt=""
loading="lazy"
decoding="async"
class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
@click="emit('open', note)"
/>
<div v-if="otherAttachments.length" class="mb-2 flex flex-wrap gap-1">
<span
v-for="att in otherAttachments"
:key="att.id"
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-white/10 dark:text-neutral-400"
>
<Icon name="paperclip" />
<span class="max-w-[140px] truncate">{{ att.filename || "file" }}</span>
</span>
</div>
<div v-if="note.previews.length" class="mb-2 flex flex-col gap-2">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
</div>
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
focusable div; text notes keep a semantic button. -->
<template v-if="note.kind === 'list'">
<div
role="button"
tabindex="0"
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
</div>
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
</template>
<button
v-else
type="button"
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
@click="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="note.body" :links="note.links" />
</div>
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
Empty note
</p>
</button>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
isOverdue(note.remind_at)
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
"
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ formatReminder(note.remind_at) }}
<!-- Recurrence was only ever shown in the reminders list; carried onto the
card so unifying them didn't quietly drop it, and so a repeating note
reads as repeating on the board too. -->
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
</span>
<!-- Clearing a reminder is UPKEEP, and upkeep that costs three clicks is
upkeep people stop doing — so it happens right here rather than by
opening the note. Shown wherever a note carries a reminder, the board
included, not only in the reminders lens.
Always visible rather than hover-revealed: a finger cannot hover, and
these are the primary action for a due note (task 2697). -->
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="completeReminder">
Done
</button>
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snoozeReminder(60)">1h</button>
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snoozeReminder(1440)">1d</button>
</div>
<div v-if="trashCountdown" class="mt-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
trashUrgent
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
"
:title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ trashCountdown }}
</span>
</div>
<!-- The card's controls: the drag grip, then the action set.
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
them where the device can actually use them. Where a pointer hovers they lift
out of flow into the floating top-corner pills they have always been; where
nothing can hover they stay here, in flow, as a footer row. The alternative
(a permanently visible overlay) sat on top of the note's own title, which is
what a phone was showing.
`hover-reveal` still handles the visibility half — see style.css. -->
<div class="note-actions">
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
drag. Board views only (reorderable). A pointer-only affordance — there is
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
to us instead of the browser's scrolling. -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<div
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
aria-label="Delete forever"
@click="notes.deleteForever(note.id)"
>
<Icon name="trash" />
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
:aria-pressed="note.pinned"
@click="notes.setPinned(note.id, !note.pinned)"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
@click="notes.setArchived(note.id, !note.archived)"
>
<Icon name="archive" />
</button>
<button
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="notes.trash(note.id)"
>
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
</template>