From e75e3d37d0fb880b8aec1d17ec9098766c5ba4b2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 20:02:18 -0400 Subject: [PATCH] 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 --- frontend/src/adapters/local.ts | 8 +- frontend/src/adapters/repo.ts | 33 +++ frontend/src/adapters/rest.ts | 8 + frontend/src/components/AppShell.vue | 28 +-- frontend/src/components/Icon.vue | 1 + frontend/src/components/LabelsModal.vue | 13 +- frontend/src/components/NoteActions.vue | 87 +++++++ frontend/src/components/NoteCard.vue | 87 +------ frontend/src/components/NoteEditor.vue | 105 ++------- frontend/src/components/PageHeader.vue | 16 ++ frontend/src/components/ReminderActions.vue | 30 +++ frontend/src/notes/colors.ts | 6 + frontend/src/notes/datetime.ts | 8 +- frontend/src/views/AccountView.vue | 27 +-- frontend/src/views/BoardView.vue | 42 ++-- frontend/src/views/SettingsView.vue | 244 ++++++++------------ frontend/src/views/SyncView.vue | 27 +-- 17 files changed, 348 insertions(+), 422 deletions(-) create mode 100644 frontend/src/components/NoteActions.vue create mode 100644 frontend/src/components/PageHeader.vue create mode 100644 frontend/src/components/ReminderActions.vue diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index ca2d417..334a894 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -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(new Error(NEEDS_SERVER)), + save: () => Promise.reject(new Error(NEEDS_SERVER)), + sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)), + }, + shares: { directory: () => invoke("shares_directory"), list: (noteId) => invoke("shares_list", { noteId }), diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 3b28baa..f83540e 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -158,6 +158,38 @@ export interface SharesRepo { unshare(noteId: string, shareId: string): Promise; } +/** 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; + save(values: Record): Promise; + /** Send a test email with the saved mail settings; answers who it went to. */ + sendTestEmail(): Promise<{ to: string }>; +} + export interface SavedFiltersRepo { list(): Promise; create(name: string, params: NoteFacets): Promise; @@ -172,5 +204,6 @@ export interface Repo { labels: LabelsRepo; notes: NotesRepo; savedFilters: SavedFiltersRepo; + settings: SettingsRepo; shares: SharesRepo; } diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index d039a47..2ed7a7d 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -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(`/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("/api/users/directory"), list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares, diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 28f2507..7b11d1f 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -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' : ''" >