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' : ''"
>