web: shared note actions, reminder chips, page header, formatters and settings seam

From the audit (#5179, web half).

- NoteActions: share, pin, archive and trash (restore and delete forever
  when trashed), as both the card and the editor offer them. The editor's
  history toggle goes in its slot, and it closes on `acted`.
- ReminderActions: the Done / 1h / 1d chips on the card and in the editor,
  which are now the same chips.
- PageHeader: the back-to-board header that Settings, Sync and Linked
  devices each wrote out, now with a `back` icon from the shared set.
- notes/datetime: formatShortDateTime (was formatReminder and the editor's
  revLabel) and formatDateTime (the two `fmt` copies).
- notes/colors: labelDotClasses (the sidebar's and the tag manager's
  labelDot).
- Drawer links use exact-active-class instead of route.name ternaries.
- BoardView binds its three grids from one gridBinds object.
- Settings goes through repo.settings (rest, plus a local adapter that
  answers "needs a server") and shows load failures through AsyncState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:02:18 -04:00
co-authored by Claude Opus 5.5
parent 05c82c2362
commit e75e3d37d0
17 changed files with 348 additions and 422 deletions
+13 -92
View File
@@ -4,15 +4,16 @@ import { useNotesStore } from "../stores/notes";
import { errorMessage } from "../api/errors";
import { rendersInline } from "../notes/attachments";
import Icon from "./Icon.vue";
import NoteActions from "./NoteActions.vue";
import ReminderActions from "./ReminderActions.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { formatShortDateTime, fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors";
import { canEditText, isOwnNote } from "../notes/sharing";
import { useUiStore } from "../stores/ui";
import {
afterEnter,
type EditorBlock,
@@ -119,7 +120,6 @@ const bodyPlaceholder = "Take a note…";
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to
// change and nothing else is; at `view` it is read-only. The server enforces both —
// this only stops the editor offering what would be refused.
const ui = useUiStore();
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
// On the desktop too: linked, it shares through the server; unlinked, the dialog
@@ -427,12 +427,6 @@ async function onRecurrenceChange(e: Event) {
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[]) {
@@ -516,12 +510,6 @@ async function onPaste(e: ClipboardEvent) {
}
}
// ---- 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[]>([]);
@@ -549,10 +537,6 @@ async function restoreRevisionAt(revId: string) {
baseline.value = { body: updated.body };
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 s = rev.body.trim().replace(/\s+/g, " ");
if (!s) return "(empty)";
@@ -770,19 +754,7 @@ function revPreview(rev: NoteRevision): string {
<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>
<ReminderActions :note-id="liveNote.id" />
</div>
</div>
@@ -797,7 +769,7 @@ function revPreview(rev: NoteRevision): string {
</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="shrink-0 tabular-nums text-neutral-400">{{ formatShortDateTime(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"
@@ -842,71 +814,20 @@ function revPreview(rev: NoteRevision): string {
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="canShare && !liveNote.trashed"
type="button"
class="icon-btn"
:class="liveNote.shared ? 'text-brand-700 dark:text-brand' : ''"
title="Share"
aria-label="Share"
@click="ui.openShare(liveNote.id)"
>
<Icon name="users" />
</button>
<button
v-if="!isCreate && own"
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>
<!-- Pin and archive are everyone's own on a shared note (#5176); trash is the owner's. -->
<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>
<NoteActions v-if="!isCreate" :note="liveNote" :own="own" :can-share="canShare" @acted="finish">
<button
v-if="own"
type="button"
class="icon-btn"
title="Move to trash"
@click="act(() => notes.trash(liveNote.id))"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
title="Version history"
aria-label="Version history"
:aria-pressed="showHistory"
@click="toggleHistory"
>
<Icon name="trash" />
<Icon name="history" />
</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>
</NoteActions>
<button
type="button"
:class="