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
+7 -1
View File
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo } from "./repo";
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo, ServerSetting } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -91,6 +91,12 @@ export const local: Repo = {
// Sharing is between accounts on a server, so these go to the linked one (#5175).
// Unlinked, each rejects with the core's explanation, which the dialog shows.
settings: {
list: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
save: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)),
},
shares: {
directory: () => invoke<Directory>("shares_directory"),
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
+33
View File
@@ -158,6 +158,38 @@ export interface SharesRepo {
unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
}
/** One admin setting as the server describes it: its value, and enough about it to
* draw the right control. */
export interface ServerSetting {
key: string;
type: "string" | "bool" | "int";
value: SettingValue;
default: SettingValue;
label: string;
description: string;
group: string;
/** Ints only, and nullable: the registry's bounds, so the number input can refuse
* an out-of-range value before the round trip. */
minimum: number | null;
maximum: number | null;
/** Strings: the allowed values, drawn as a choice. */
choices: string[] | null;
/** A credential (the SMTP password). Its value never comes back; `is_set` says
* whether one is saved, and leaving the field empty keeps it. */
secret: boolean;
is_set: boolean | null;
}
export type SettingValue = string | boolean | number;
/** The server's admin settings. A server's, so the offline desktop has none. */
export interface SettingsRepo {
list(): Promise<ServerSetting[]>;
save(values: Record<string, SettingValue>): Promise<ServerSetting[]>;
/** Send a test email with the saved mail settings; answers who it went to. */
sendTestEmail(): Promise<{ to: string }>;
}
export interface SavedFiltersRepo {
list(): Promise<SavedFilter[]>;
create(name: string, params: NoteFacets): Promise<SavedFilter>;
@@ -172,5 +204,6 @@ export interface Repo {
labels: LabelsRepo;
notes: NotesRepo;
savedFilters: SavedFiltersRepo;
settings: SettingsRepo;
shares: SharesRepo;
}
+8
View File
@@ -22,6 +22,7 @@ import type {
NoteListQuery,
ChecklistItemChanges,
Repo,
ServerSetting,
} from "./repo";
// Render a board query to the GET /api/notes query string. Mirrors the param
@@ -116,6 +117,13 @@ export const rest: Repo = {
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
},
settings: {
list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings,
save: async (values) =>
(await api.patch<{ settings: ServerSetting[] }>("/api/settings", { settings: values })).settings,
sendTestEmail: () => api.post<{ to: string }>("/api/settings/test-email"),
},
shares: {
directory: () => api.get<Directory>("/api/users/directory"),
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
+10 -18
View File
@@ -17,7 +17,7 @@ import ShareDialog from "./ShareDialog.vue";
import { isDesktop } from "../desktop/bridge";
import { useLensName } from "../composables/useLensName";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors";
import { labelDotClasses } from "../notes/colors";
const route = useRoute();
const router = useRouter();
@@ -191,14 +191,6 @@ onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
});
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
// green on a card must be green here, or the sidebar stops being a way to find it.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
// The board lenses — the routes a search can happen *within*. Searching while looking
// at Trash should search Trash, not silently move you.
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
@@ -412,7 +404,7 @@ async function signOut() {
:class="drawer ? 'translate-x-0' : ''"
>
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
<RouterLink to="/" class="nav-link" exact-active-class="nav-link-active">
<Icon name="note" /> Notes
</RouterLink>
@@ -434,11 +426,11 @@ async function signOut() {
:key="lb.id"
:to="`/label/${lb.id}`"
class="nav-link"
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
exact-active-class="nav-link-active"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(lb)"
:class="labelDotClasses(lb)"
></span>
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
@@ -466,23 +458,23 @@ async function signOut() {
</RouterLink>
</template>
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
<RouterLink to="/archive" class="nav-link mt-3" exact-active-class="nav-link-active">
<Icon name="archive" /> Archive
</RouterLink>
<RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
<RouterLink to="/trash" class="nav-link" exact-active-class="nav-link-active">
<Icon name="trash" /> Trash
</RouterLink>
<RouterLink
to="/reminders"
class="nav-link"
:class="route.name === 'reminders' ? 'nav-link-active' : ''"
exact-active-class="nav-link-active"
>
<Icon name="bell" /> Reminders
</RouterLink>
<RouterLink
to="/timeline"
class="nav-link"
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
exact-active-class="nav-link-active"
>
<Icon name="calendar" /> Timeline
</RouterLink>
@@ -501,14 +493,14 @@ async function signOut() {
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
>
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
<RouterLink to="/account" class="nav-link" exact-active-class="nav-link-active">
<Icon name="device" /> Linked devices
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="nav-link"
:class="route.name === 'settings' ? 'nav-link-active' : ''"
exact-active-class="nav-link-active"
>
<Icon name="settings" /> Settings
</RouterLink>
+1
View File
@@ -19,6 +19,7 @@ const paths: Record<string, string> = {
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
back: '<path d="m15 18-6-6 6-6"/>',
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
+3 -10
View File
@@ -5,6 +5,7 @@ import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
labelDotClasses,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
@@ -31,14 +32,6 @@ async function rename(id: string, value: string) {
if (name) await labels.rename(id, name);
}
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
// picked one — so this screen agrees with the chips everywhere else. The ring in the
// swatch grid below follows the same resolution, so opening the picker highlights
// what you can already see rather than nothing at all.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
function openColor(id: string) {
mergeFor.value = null;
pickerFor.value = pickerFor.value === id ? null : id;
@@ -85,7 +78,7 @@ async function doMerge(sourceId: string, targetId: string) {
<button
type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb)"
:class="labelDotClasses(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change tag color"
@click="openColor(lb.id)"
@@ -150,7 +143,7 @@ async function doMerge(sourceId: string, targetId: string) {
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t)"
:class="labelDotClasses(t)"
></span>
<span class="truncate">{{ t.name }}</span>
</button>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useNotesStore } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
// A note's own actions, as the card and the editor both offer them. Pin and archive
// are everyone's own (#5176); share and trash are the owner's. A note shared with
// you is never in your Trash, so restore and delete forever are the owner's too.
// The default slot sits after Share, for the editor's history toggle.
const props = defineProps<{ note: Note; own: boolean; canShare: boolean }>();
const emit = defineEmits<{
/** A pin, archive, trash, restore or delete landed. The editor closes on it. */
(e: "acted"): void;
}>();
const notes = useNotesStore();
const ui = useUiStore();
async function act(fn: (id: string) => Promise<unknown>): Promise<void> {
await fn(props.note.id);
emit("acted");
}
</script>
<template>
<button
v-if="canShare && !note.trashed"
type="button"
class="icon-btn"
:class="note.shared ? 'text-brand-700 dark:text-brand' : ''"
title="Share"
aria-label="Share"
@click="ui.openShare(note.id)"
>
<Icon name="users" />
</button>
<slot />
<template v-if="note.trashed">
<template v-if="own">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="act(notes.restore)">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
aria-label="Delete forever"
@click="act(notes.deleteForever)"
>
<Icon name="trash" />
</button>
</template>
</template>
<template v-else>
<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="act((id) => notes.setPinned(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="act((id) => notes.setArchived(id, !note.archived))"
>
<Icon name="archive" />
</button>
<button
v-if="own"
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="act(notes.trash)"
>
<Icon name="trash" />
</button>
</template>
</template>
+6 -81
View File
@@ -7,6 +7,8 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import NoteActions from "./NoteActions.vue";
import ReminderActions from "./ReminderActions.vue";
import {
cardIdAt,
draggingId,
@@ -14,9 +16,8 @@ import {
endCardDrag,
overId,
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { formatShortDateTime, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
import { useUiStore } from "../stores/ui";
import { canEditText, isOwnNote } from "../notes/sharing";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
@@ -34,7 +35,6 @@ const emit = defineEmits<{
}>();
const notes = useNotesStore();
const config = useConfigStore();
const ui = useUiStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
// the owner's controls; at `view` its checkboxes are inert too. The desktop offers
@@ -193,15 +193,7 @@ watch(
},
);
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");
}
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
@@ -385,24 +377,14 @@ const tagColors = computed<Record<string, string>>(() => {
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ formatReminder(note.remind_at) }}
{{ formatShortDateTime(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>
<ReminderActions :note-id="note.id" @changed="emit('reminder-changed')" />
</div>
<div v-if="trashCountdown" class="mt-2">
@@ -459,68 +441,11 @@ const tagColors = computed<Record<string, string>>(() => {
<Icon name="grip" />
</button>
<!-- Pin and archive are everyone's own (#5176); share and trash are the owner's.
A note shared with you is never in your Trash, so the trashed set is too. -->
<div
v-if="own || !note.trashed"
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
v-if="canShare"
type="button"
class="icon-btn"
title="Share"
aria-label="Share"
@click="ui.openShare(note.id)"
>
<Icon name="users" />
</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
v-if="own"
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="notes.trash(note.id)"
>
<Icon name="trash" />
</button>
</template>
<NoteActions :note="note" :own="own" :can-share="canShare" />
</div>
</div>
</div>
+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="
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import Icon from "./Icon.vue";
// The top of a page that sits outside the board (Settings, Sync, Linked devices):
// a way back to the board, and the page's name.
defineProps<{ title: string }>();
</script>
<template>
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<Icon name="back" />
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">{{ title }}</h1>
</header>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useNotesStore } from "../stores/notes";
// Done, snooze an hour, snooze a day: the upkeep a due reminder asks for, one tap
// each wherever the reminder shows (card and editor). Always visible rather than
// hover-revealed: a finger cannot hover, and these are the point of a due note.
const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{
/** Completed or snoozed. For a host holding its own list (the reminders lens). */
(e: "changed"): void;
}>();
const notes = useNotesStore();
async function complete(): Promise<void> {
await notes.completeReminder(props.noteId);
emit("changed");
}
async function snooze(minutes: number): Promise<void> {
await notes.snoozeReminder(props.noteId, minutes);
emit("changed");
}
</script>
<template>
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="complete">Done</button>
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snooze(60)">1h</button>
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snooze(1440)">1d</button>
</template>
+6
View File
@@ -137,6 +137,12 @@ export function labelChipClasses(label: { name: string; color?: string | null })
return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`;
}
/** The small round swatch for a tag (sidebar, tag manager): the colour it actually
* wears, derived from its name when nobody picked one, as on every chip. */
export function labelDotClasses(label: { name: string; color?: string | null }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
default: "Default",
red: "Red",
+7 -1
View File
@@ -13,7 +13,8 @@ export function fromLocalInput(local: string): string | null {
return new Date(local).toISOString();
}
export function formatReminder(iso: string | null): string {
// A moment, short: "Oct 7, 4:05 PM". A reminder on a card, a revision in history.
export function formatShortDateTime(iso: string | null): string {
if (!iso) return "";
return new Date(iso).toLocaleString(undefined, {
month: "short",
@@ -23,6 +24,11 @@ export function formatReminder(iso: string | null): string {
});
}
// A moment, in full, or `empty` when there is none ("last synced never").
export function formatDateTime(iso: string | null, empty = "never"): string {
return iso ? new Date(iso).toLocaleString() : empty;
}
export function isOverdue(iso: string | null): boolean {
return !!iso && new Date(iso).getTime() < Date.now();
}
+4 -23
View File
@@ -7,7 +7,9 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
import PageHeader from "../components/PageHeader.vue";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
// Per-user (not admin) management of linked native clients — the Tauri desktop and
// Android apps authenticate sync with a device bearer token issued here.
@@ -66,11 +68,6 @@ async function revoke(id: string, name: string) {
}
}
function fmt(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
onMounted(() => {
void load();
});
@@ -78,23 +75,7 @@ onMounted(() => {
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
</header>
<PageHeader title="Linked devices" />
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
Link the Inkwell desktop or mobile app to sync your notes. Create a device token here, then
@@ -170,7 +151,7 @@ onMounted(() => {
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
<p class="text-xs text-neutral-400">
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
Linked {{ formatDateTime(d.created_at) }} · last synced {{ formatDateTime(d.last_used_at) }}
</p>
</div>
</div>
+14 -28
View File
@@ -281,6 +281,17 @@ async function onDrop(targetId: string) {
order.splice(toIdx, 0, from);
await notes.reorder(order);
}
/** What every grid on this board takes besides its notes: the keyboard focus, and
* opening and reordering cards. */
const gridBinds = computed(() => ({
activeId: focusedId.value,
reorderable: true,
onOpen: openEditor,
onDragstart: onDragStart,
onDragend: onDragEnd,
onDrop,
}));
</script>
<template>
@@ -340,42 +351,17 @@ async function onDrop(targetId: string) {
<template v-if="isMainBoard">
<section v-if="pinnedNotes.length">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
<NoteGrid
:notes="pinnedNotes"
:active-id="focusedId"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@dragend="onDragEnd"
@drop="onDrop"
/>
<NoteGrid :notes="pinnedNotes" v-bind="gridBinds" />
</section>
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
Others
</h2>
<NoteGrid
:notes="otherNotes"
:active-id="focusedId"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@dragend="onDragEnd"
@drop="onDrop"
/>
<NoteGrid :notes="otherNotes" v-bind="gridBinds" />
</section>
</template>
<NoteGrid
v-else
:notes="notes.items"
:active-id="focusedId"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@dragend="onDragEnd"
@drop="onDrop"
/>
<NoteGrid v-else :notes="notes.items" v-bind="gridBinds" />
</template>
</AsyncState>
</div>
+99 -145
View File
@@ -1,40 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
import type { ServerSetting, SettingValue } from "../adapters/repo";
import { useConfigStore } from "../stores/config";
import AsyncState from "../components/AsyncState.vue";
import BaseButton from "../components/BaseButton.vue";
import PageHeader from "../components/PageHeader.vue";
import InviteList from "../components/InviteList.vue";
import AccountList from "../components/AccountList.vue";
import GroupList from "../components/GroupList.vue";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
interface SettingItem {
key: string;
type: "string" | "bool" | "int";
value: string | boolean | number;
default: string | boolean | number;
label: string;
description: string;
group: string;
// Ints only, and nullable: the server sends the registry's bounds so the number
// input can refuse an out-of-range value before the round trip.
minimum: number | null;
maximum: number | null;
// Strings: the allowed values, drawn as a choice.
choices: string[] | null;
// A credential (the SMTP password). Its value never comes back; `is_set` says
// whether one is saved, and leaving the field empty keeps it.
secret: boolean;
is_set: boolean | null;
}
const config = useConfigStore();
const ui = useUiStore();
const items = ref<SettingItem[]>([]);
const original = ref<Record<string, string | boolean | number>>({});
const items = ref<ServerSetting[]>([]);
const original = ref<Record<string, SettingValue>>({});
const loading = ref(true);
const loadError = ref("");
const saving = ref(false);
const error = ref("");
const saved = ref(false);
@@ -46,7 +30,7 @@ watch(dirty, (d) => {
});
const groups = computed(() => {
const map = new Map<string, SettingItem[]>();
const map = new Map<string, ServerSetting[]>();
for (const it of items.value) {
const arr = map.get(it.group) ?? [];
arr.push(it);
@@ -55,19 +39,18 @@ const groups = computed(() => {
return Array.from(map, ([name, entries]) => ({ name, entries }));
});
function snapshot(list: SettingItem[]) {
function snapshot(list: ServerSetting[]) {
original.value = Object.fromEntries(list.map((it) => [it.key, it.value]));
}
async function load() {
loading.value = true;
error.value = "";
loadError.value = "";
try {
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
items.value = res.settings;
snapshot(res.settings);
} catch {
error.value = "Could not load settings.";
items.value = await repo.settings.list();
snapshot(items.value);
} catch (e) {
loadError.value = errorMessage(e, "Could not load settings.");
} finally {
loading.value = false;
}
@@ -77,12 +60,11 @@ async function save() {
error.value = "";
saved.value = false;
saving.value = true;
const payload: Record<string, string | boolean | number> = {};
const payload: Record<string, SettingValue> = {};
for (const it of items.value) payload[it.key] = it.value;
try {
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
items.value = res.settings;
snapshot(res.settings);
items.value = await repo.settings.save(payload);
snapshot(items.value);
saved.value = true;
await config.reload(); // header/site name reflects changes immediately
} catch (e) {
@@ -98,7 +80,7 @@ const testing = ref(false);
async function sendTestEmail() {
testing.value = true;
try {
const res = await api.post<{ to: string }>("/api/settings/test-email");
const res = await repo.settings.sendTestEmail();
ui.showToast(`Test email sent to ${res.to}.`);
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't send a test email."));
@@ -112,121 +94,93 @@ onMounted(load);
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Settings</h1>
</header>
<PageHeader title="Settings" />
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
<AsyncState :loading="loading" :error="loadError" error-title="Couldn't load settings" @retry="load">
<form class="flex flex-col gap-8" @submit.prevent="save">
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
<div v-else-if="error && !items.length" class="py-20 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load settings</h2>
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
<button
type="button"
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="load"
>
Retry
</button>
</div>
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-6">
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
it.label
}}</label>
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-6">
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
it.label
}}</label>
<input
v-if="it.type === 'bool'"
:id="it.key"
type="checkbox"
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:checked="Boolean(it.value)"
@change="it.value = ($event.target as HTMLInputElement).checked"
/>
<!-- min/max come from the registry. The server rejects out-of-range
values regardless — this is so the browser says so first, rather than
letting someone type a hop count that would disable a protection and
only learn about it from an error banner. -->
<input
v-else-if="it.type === 'int'"
:id="it.key"
type="number"
:min="it.minimum ?? undefined"
:max="it.maximum ?? undefined"
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="Number(it.value)"
@input="it.value = Number(($event.target as HTMLInputElement).value)"
/>
<select
v-else-if="it.choices"
:id="it.key"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@change="it.value = ($event.target as HTMLSelectElement).value"
>
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
</select>
<input
v-else-if="it.secret"
:id="it.key"
type="password"
autocomplete="new-password"
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
/>
<input
v-else
:id="it.key"
type="text"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
/>
<input
v-if="it.type === 'bool'"
:id="it.key"
type="checkbox"
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:checked="Boolean(it.value)"
@change="it.value = ($event.target as HTMLInputElement).checked"
/>
<!-- min/max come from the registry. The server rejects out-of-range
values regardless — this is so the browser says so first, rather than
letting someone type a hop count that would disable a protection and
only learn about it from an error banner. -->
<input
v-else-if="it.type === 'int'"
:id="it.key"
type="number"
:min="it.minimum ?? undefined"
:max="it.maximum ?? undefined"
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="Number(it.value)"
@input="it.value = Number(($event.target as HTMLInputElement).value)"
/>
<select
v-else-if="it.choices"
:id="it.key"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@change="it.value = ($event.target as HTMLSelectElement).value"
>
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
</select>
<input
v-else-if="it.secret"
:id="it.key"
type="password"
autocomplete="new-password"
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
/>
<input
v-else
:id="it.key"
type="text"
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
/>
</div>
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
</div>
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
</div>
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
:disabled="testing || dirty"
@click="sendTestEmail"
>
Send test email
</button>
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
</div>
</section>
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
:disabled="testing || dirty"
@click="sendTestEmail"
>
Send test email
</button>
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
</div>
</section>
<div class="flex items-center gap-3">
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</form>
<div class="flex items-center gap-3">
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</form>
</AsyncState>
<!-- Outside the settings form: each invite, account and group action saves on its own,
and the form's Save button has nothing to do with them. -->
+4 -23
View File
@@ -4,6 +4,7 @@ import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import PageHeader from "../components/PageHeader.vue";
import DesktopMenuEntry from "../components/DesktopMenuEntry.vue";
import {
SUGGESTED_CAPTURE_SHORTCUT,
@@ -22,6 +23,7 @@ import {
type UpdateStatus,
} from "../desktop/bridge";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
// state, not an incomplete setup — the app is local-first and fully usable having
@@ -309,11 +311,6 @@ async function disconnect() {
}
}
function fmt(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
// A background cycle finished — redraw from it, so this screen is never staler
// than the board behind it.
let stopSyncListener: (() => void) | null = null;
@@ -335,23 +332,7 @@ onBeforeUnmount(() => stopSyncListener?.());
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Sync</h1>
</header>
<PageHeader title="Sync" />
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
@@ -366,7 +347,7 @@ onBeforeUnmount(() => stopSyncListener?.());
</p>
<p v-if="linkedAs" class="mt-0.5 text-xs text-neutral-400">as {{ linkedAs }}</p>
<p class="mt-1 text-xs text-neutral-400">
Last synced {{ fmt(status?.last_sync_at ?? null) }}
Last synced {{ formatDateTime(status?.last_sync_at ?? null) }}
<span v-if="pending"> · unsent changes on this device</span>
</p>
<p class="mt-0.5 text-xs text-neutral-400">