@broberg/app-shell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import { NotificationRow } from '@broberg/notifications/types';
2
+ export { NotificationRow } from '@broberg/notifications/types';
3
+
4
+ /** One sidebar entry. `icon` is whatever your framework renders (an element). */
5
+ interface NavItem<Icon = unknown> {
6
+ id: string;
7
+ label: string;
8
+ href: string;
9
+ icon?: Icon;
10
+ /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */
11
+ badge?: number;
12
+ }
13
+ interface NavGroup<Icon = unknown> {
14
+ label: string;
15
+ items: NavItem<Icon>[];
16
+ }
17
+ /**
18
+ * Is this item the current page? Exact match for "/", otherwise the path is the
19
+ * item or below it ("/board/123" is on "/board", "/boardroom" is not).
20
+ */
21
+ declare function isActivePath(href: string, currentPath: string): boolean;
22
+ /** "99+" above 99, nothing for 0 or less. */
23
+ declare function badgeLabel(n: number | undefined): string | null;
24
+ /** Read a boolean flag from localStorage; storage errors read as `fallback`. */
25
+ declare function readFlag(key: string, fallback: boolean): boolean;
26
+ declare function writeFlag(key: string, value: boolean): void;
27
+ declare function readSet(key: string): Set<string>;
28
+ declare function writeSet(key: string, value: Set<string>): void;
29
+ /**
30
+ * Two initials for the avatar fallback: first letter of the first and the last
31
+ * word, upper-cased. One word gives one letter; nothing gives "?".
32
+ *
33
+ * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar
34
+ * is the Broberg ID `picture`, and without one, initials. Gravatar would send a
35
+ * hash of the user's email to a US service on every render.
36
+ */
37
+ declare function initials(name: string | undefined, email?: string): string;
38
+
39
+ /** Where the bell gets its data. Swap it to point at any backend. */
40
+ interface NotificationSource {
41
+ /** The rows to show (newest first). */
42
+ loadRows(): Promise<NotificationRow[]>;
43
+ /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */
44
+ countUnseen(): Promise<number>;
45
+ /** Returns the ids that actually transitioned to seen. */
46
+ markSeen(ids: readonly string[]): Promise<string[]>;
47
+ markAllSeen(): Promise<string[]>;
48
+ /** Called with a callback that re-counts; return an unsubscribe. Optional. */
49
+ subscribe?(onChange: () => void): () => void;
50
+ }
51
+ /**
52
+ * The default source: a URL on your own backend, shaped after
53
+ * @broberg/notifications' server core so `createNotifications` sits right
54
+ * behind it.
55
+ *
56
+ * GET {url} → { items: NotificationRow[], unseen: number }
57
+ * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }
58
+ * POST {url}/seen-all → { clearedIds: string[], count: number }
59
+ *
60
+ * It re-counts when the window regains focus or the tab becomes visible, and on
61
+ * a timer (default 60 s). A failed GET THROWS — the bell shows «could not
62
+ * load», never an empty list: "could not fetch" and "nothing new" must not look
63
+ * the same.
64
+ */
65
+ declare function createFetchNotificationSource(opts: {
66
+ url: string;
67
+ fetch?: typeof fetch;
68
+ /** Re-count interval in ms. 0 turns it off. Default 60 000. */
69
+ pollMs?: number;
70
+ credentials?: RequestCredentials;
71
+ }): NotificationSource;
72
+ /** An in-memory source for demos and tests. */
73
+ declare function createMemoryNotificationSource(initial?: NotificationRow[]): NotificationSource & {
74
+ push(n: NotificationRow): void;
75
+ rows(): NotificationRow[];
76
+ };
77
+ /** "nu", "5 min", "3 t", "i går", "4 d" (da) or the English equivalents. */
78
+ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: number): string;
79
+ declare const TEXT: {
80
+ readonly da: {
81
+ readonly openMenu: "Åbn menu";
82
+ readonly collapse: "Skjul sidemenu";
83
+ readonly expand: "Vis sidemenu";
84
+ readonly notifications: "Notifikationer";
85
+ readonly markAllRead: "Markér alle læst";
86
+ readonly noNotifications: "Ingen notifikationer endnu.";
87
+ readonly couldNotLoad: "Kunne ikke hente notifikationer.";
88
+ readonly seeAll: "Vis alle";
89
+ readonly close: "Luk";
90
+ readonly account: "Konto";
91
+ readonly signOut: "Log ud";
92
+ readonly theme: "Tema";
93
+ readonly light: "Lys";
94
+ readonly dark: "Mørk";
95
+ readonly system: "System";
96
+ readonly palette: "Palet";
97
+ readonly surfaces: "Flader";
98
+ readonly flat: "Flade";
99
+ readonly layered: "Lag";
100
+ readonly backdrop: "Baggrund";
101
+ readonly neurons: "Neuroner";
102
+ readonly plain: "Ren";
103
+ };
104
+ readonly en: {
105
+ readonly openMenu: "Open menu";
106
+ readonly collapse: "Hide sidebar";
107
+ readonly expand: "Show sidebar";
108
+ readonly notifications: "Notifications";
109
+ readonly markAllRead: "Mark all read";
110
+ readonly noNotifications: "No notifications yet.";
111
+ readonly couldNotLoad: "Could not load notifications.";
112
+ readonly seeAll: "See all";
113
+ readonly close: "Close";
114
+ readonly account: "Account";
115
+ readonly signOut: "Sign out";
116
+ readonly theme: "Theme";
117
+ readonly light: "Light";
118
+ readonly dark: "Dark";
119
+ readonly system: "System";
120
+ readonly palette: "Palette";
121
+ readonly surfaces: "Surfaces";
122
+ readonly flat: "Flat";
123
+ readonly layered: "Layered";
124
+ readonly backdrop: "Backdrop";
125
+ readonly neurons: "Neurons";
126
+ readonly plain: "Plain";
127
+ };
128
+ };
129
+ type Lang = keyof typeof TEXT;
130
+
131
+ export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
@@ -0,0 +1,131 @@
1
+ import { NotificationRow } from '@broberg/notifications/types';
2
+ export { NotificationRow } from '@broberg/notifications/types';
3
+
4
+ /** One sidebar entry. `icon` is whatever your framework renders (an element). */
5
+ interface NavItem<Icon = unknown> {
6
+ id: string;
7
+ label: string;
8
+ href: string;
9
+ icon?: Icon;
10
+ /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */
11
+ badge?: number;
12
+ }
13
+ interface NavGroup<Icon = unknown> {
14
+ label: string;
15
+ items: NavItem<Icon>[];
16
+ }
17
+ /**
18
+ * Is this item the current page? Exact match for "/", otherwise the path is the
19
+ * item or below it ("/board/123" is on "/board", "/boardroom" is not).
20
+ */
21
+ declare function isActivePath(href: string, currentPath: string): boolean;
22
+ /** "99+" above 99, nothing for 0 or less. */
23
+ declare function badgeLabel(n: number | undefined): string | null;
24
+ /** Read a boolean flag from localStorage; storage errors read as `fallback`. */
25
+ declare function readFlag(key: string, fallback: boolean): boolean;
26
+ declare function writeFlag(key: string, value: boolean): void;
27
+ declare function readSet(key: string): Set<string>;
28
+ declare function writeSet(key: string, value: Set<string>): void;
29
+ /**
30
+ * Two initials for the avatar fallback: first letter of the first and the last
31
+ * word, upper-cased. One word gives one letter; nothing gives "?".
32
+ *
33
+ * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar
34
+ * is the Broberg ID `picture`, and without one, initials. Gravatar would send a
35
+ * hash of the user's email to a US service on every render.
36
+ */
37
+ declare function initials(name: string | undefined, email?: string): string;
38
+
39
+ /** Where the bell gets its data. Swap it to point at any backend. */
40
+ interface NotificationSource {
41
+ /** The rows to show (newest first). */
42
+ loadRows(): Promise<NotificationRow[]>;
43
+ /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */
44
+ countUnseen(): Promise<number>;
45
+ /** Returns the ids that actually transitioned to seen. */
46
+ markSeen(ids: readonly string[]): Promise<string[]>;
47
+ markAllSeen(): Promise<string[]>;
48
+ /** Called with a callback that re-counts; return an unsubscribe. Optional. */
49
+ subscribe?(onChange: () => void): () => void;
50
+ }
51
+ /**
52
+ * The default source: a URL on your own backend, shaped after
53
+ * @broberg/notifications' server core so `createNotifications` sits right
54
+ * behind it.
55
+ *
56
+ * GET {url} → { items: NotificationRow[], unseen: number }
57
+ * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }
58
+ * POST {url}/seen-all → { clearedIds: string[], count: number }
59
+ *
60
+ * It re-counts when the window regains focus or the tab becomes visible, and on
61
+ * a timer (default 60 s). A failed GET THROWS — the bell shows «could not
62
+ * load», never an empty list: "could not fetch" and "nothing new" must not look
63
+ * the same.
64
+ */
65
+ declare function createFetchNotificationSource(opts: {
66
+ url: string;
67
+ fetch?: typeof fetch;
68
+ /** Re-count interval in ms. 0 turns it off. Default 60 000. */
69
+ pollMs?: number;
70
+ credentials?: RequestCredentials;
71
+ }): NotificationSource;
72
+ /** An in-memory source for demos and tests. */
73
+ declare function createMemoryNotificationSource(initial?: NotificationRow[]): NotificationSource & {
74
+ push(n: NotificationRow): void;
75
+ rows(): NotificationRow[];
76
+ };
77
+ /** "nu", "5 min", "3 t", "i går", "4 d" (da) or the English equivalents. */
78
+ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: number): string;
79
+ declare const TEXT: {
80
+ readonly da: {
81
+ readonly openMenu: "Åbn menu";
82
+ readonly collapse: "Skjul sidemenu";
83
+ readonly expand: "Vis sidemenu";
84
+ readonly notifications: "Notifikationer";
85
+ readonly markAllRead: "Markér alle læst";
86
+ readonly noNotifications: "Ingen notifikationer endnu.";
87
+ readonly couldNotLoad: "Kunne ikke hente notifikationer.";
88
+ readonly seeAll: "Vis alle";
89
+ readonly close: "Luk";
90
+ readonly account: "Konto";
91
+ readonly signOut: "Log ud";
92
+ readonly theme: "Tema";
93
+ readonly light: "Lys";
94
+ readonly dark: "Mørk";
95
+ readonly system: "System";
96
+ readonly palette: "Palet";
97
+ readonly surfaces: "Flader";
98
+ readonly flat: "Flade";
99
+ readonly layered: "Lag";
100
+ readonly backdrop: "Baggrund";
101
+ readonly neurons: "Neuroner";
102
+ readonly plain: "Ren";
103
+ };
104
+ readonly en: {
105
+ readonly openMenu: "Open menu";
106
+ readonly collapse: "Hide sidebar";
107
+ readonly expand: "Show sidebar";
108
+ readonly notifications: "Notifications";
109
+ readonly markAllRead: "Mark all read";
110
+ readonly noNotifications: "No notifications yet.";
111
+ readonly couldNotLoad: "Could not load notifications.";
112
+ readonly seeAll: "See all";
113
+ readonly close: "Close";
114
+ readonly account: "Account";
115
+ readonly signOut: "Sign out";
116
+ readonly theme: "Theme";
117
+ readonly light: "Light";
118
+ readonly dark: "Dark";
119
+ readonly system: "System";
120
+ readonly palette: "Palette";
121
+ readonly surfaces: "Surfaces";
122
+ readonly flat: "Flat";
123
+ readonly layered: "Layered";
124
+ readonly backdrop: "Backdrop";
125
+ readonly neurons: "Neurons";
126
+ readonly plain: "Plain";
127
+ };
128
+ };
129
+ type Lang = keyof typeof TEXT;
130
+
131
+ export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
package/dist/index.js ADDED
@@ -0,0 +1,192 @@
1
+ // src/index.ts
2
+ function isActivePath(href, currentPath) {
3
+ const path = (currentPath.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
4
+ const target = (href.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
5
+ if (target === "/") return path === "/";
6
+ return path === target || path.startsWith(target + "/");
7
+ }
8
+ function badgeLabel(n) {
9
+ if (!n || n <= 0) return null;
10
+ return n > 99 ? "99+" : String(n);
11
+ }
12
+ function readFlag(key, fallback) {
13
+ try {
14
+ const v = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
15
+ return v === null ? fallback : v === "1";
16
+ } catch {
17
+ return fallback;
18
+ }
19
+ }
20
+ function writeFlag(key, value) {
21
+ try {
22
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, value ? "1" : "0");
23
+ } catch {
24
+ }
25
+ }
26
+ function readSet(key) {
27
+ try {
28
+ const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
29
+ const v = raw ? JSON.parse(raw) : [];
30
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
31
+ } catch {
32
+ return /* @__PURE__ */ new Set();
33
+ }
34
+ }
35
+ function writeSet(key, value) {
36
+ try {
37
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, JSON.stringify([...value]));
38
+ } catch {
39
+ }
40
+ }
41
+ function initials(name, email) {
42
+ const source = (name ?? "").trim() || (email ?? "").split("@")[0] || "";
43
+ const words = source.split(/[\s._-]+/).filter(Boolean);
44
+ if (words.length === 0) return "?";
45
+ const first = words[0][0] ?? "";
46
+ const last = words.length > 1 ? words[words.length - 1][0] ?? "" : "";
47
+ return (first + last).toUpperCase() || "?";
48
+ }
49
+ function createFetchNotificationSource(opts) {
50
+ const base = opts.url.replace(/\/+$/, "");
51
+ const doFetch = (input, init) => (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? "include", ...init });
52
+ const get = async () => {
53
+ const res = await doFetch(base, { headers: { accept: "application/json" } });
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
55
+ const body = await res.json().catch(() => null);
56
+ if (!body || !Array.isArray(body.items)) throw new Error("response has no items array");
57
+ const items = body.items;
58
+ const unseen = typeof body.unseen === "number" ? body.unseen : items.filter((n) => n.seenAt == null).length;
59
+ return { items, unseen };
60
+ };
61
+ const post = async (path, body) => {
62
+ const res = await doFetch(`${base}${path}`, {
63
+ method: "POST",
64
+ headers: body === void 0 ? void 0 : { "content-type": "application/json" },
65
+ body: body === void 0 ? void 0 : JSON.stringify(body)
66
+ });
67
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
68
+ const r = await res.json().catch(() => ({}));
69
+ return Array.isArray(r.clearedIds) ? r.clearedIds : [];
70
+ };
71
+ return {
72
+ loadRows: async () => (await get()).items,
73
+ countUnseen: async () => (await get()).unseen,
74
+ markSeen: (ids) => post("/seen", { ids }),
75
+ markAllSeen: () => post("/seen-all"),
76
+ subscribe(onChange) {
77
+ if (typeof window === "undefined") return () => {
78
+ };
79
+ const onVisible = () => {
80
+ if (document.visibilityState === "visible") onChange();
81
+ };
82
+ window.addEventListener("focus", onChange);
83
+ document.addEventListener("visibilitychange", onVisible);
84
+ const ms = opts.pollMs ?? 6e4;
85
+ const timer = ms > 0 ? setInterval(onChange, ms) : null;
86
+ return () => {
87
+ window.removeEventListener("focus", onChange);
88
+ document.removeEventListener("visibilitychange", onVisible);
89
+ if (timer) clearInterval(timer);
90
+ };
91
+ }
92
+ };
93
+ }
94
+ function createMemoryNotificationSource(initial = []) {
95
+ let rows = initial.map((n) => ({ ...n }));
96
+ const listeners = /* @__PURE__ */ new Set();
97
+ const emit = () => listeners.forEach((l) => l());
98
+ const mark = (pred) => {
99
+ const now = Date.now();
100
+ const cleared = [];
101
+ rows = rows.map((n) => {
102
+ if (n.seenAt == null && pred(n)) {
103
+ cleared.push(n.id);
104
+ return { ...n, seenAt: now };
105
+ }
106
+ return n;
107
+ });
108
+ if (cleared.length) emit();
109
+ return cleared;
110
+ };
111
+ return {
112
+ loadRows: async () => rows.map((n) => ({ ...n })),
113
+ countUnseen: async () => rows.filter((n) => n.seenAt == null).length,
114
+ markSeen: async (ids) => mark((n) => ids.includes(n.id)),
115
+ markAllSeen: async () => mark(() => true),
116
+ subscribe(onChange) {
117
+ listeners.add(onChange);
118
+ return () => listeners.delete(onChange);
119
+ },
120
+ push(n) {
121
+ rows = [n, ...rows];
122
+ emit();
123
+ },
124
+ rows: () => rows.map((n) => ({ ...n }))
125
+ };
126
+ }
127
+ function relativeTime(ts, lang, now = Date.now()) {
128
+ const ms = typeof ts === "number" ? ts : Date.parse(ts);
129
+ if (!Number.isFinite(ms)) return "";
130
+ const m = Math.floor((now - ms) / 6e4);
131
+ if (m < 1) return lang === "da" ? "nu" : "now";
132
+ if (m < 60) return `${m} min`;
133
+ const h = Math.floor(m / 60);
134
+ if (h < 24) return lang === "da" ? `${h} t` : `${h} h`;
135
+ const d = Math.floor(h / 24);
136
+ if (d === 1) return lang === "da" ? "i g\xE5r" : "yesterday";
137
+ return `${d} d`;
138
+ }
139
+ var TEXT = {
140
+ da: {
141
+ openMenu: "\xC5bn menu",
142
+ collapse: "Skjul sidemenu",
143
+ expand: "Vis sidemenu",
144
+ notifications: "Notifikationer",
145
+ markAllRead: "Mark\xE9r alle l\xE6st",
146
+ noNotifications: "Ingen notifikationer endnu.",
147
+ couldNotLoad: "Kunne ikke hente notifikationer.",
148
+ seeAll: "Vis alle",
149
+ close: "Luk",
150
+ account: "Konto",
151
+ signOut: "Log ud",
152
+ theme: "Tema",
153
+ light: "Lys",
154
+ dark: "M\xF8rk",
155
+ system: "System",
156
+ palette: "Palet",
157
+ surfaces: "Flader",
158
+ flat: "Flade",
159
+ layered: "Lag",
160
+ backdrop: "Baggrund",
161
+ neurons: "Neuroner",
162
+ plain: "Ren"
163
+ },
164
+ en: {
165
+ openMenu: "Open menu",
166
+ collapse: "Hide sidebar",
167
+ expand: "Show sidebar",
168
+ notifications: "Notifications",
169
+ markAllRead: "Mark all read",
170
+ noNotifications: "No notifications yet.",
171
+ couldNotLoad: "Could not load notifications.",
172
+ seeAll: "See all",
173
+ close: "Close",
174
+ account: "Account",
175
+ signOut: "Sign out",
176
+ theme: "Theme",
177
+ light: "Light",
178
+ dark: "Dark",
179
+ system: "System",
180
+ palette: "Palette",
181
+ surfaces: "Surfaces",
182
+ flat: "Flat",
183
+ layered: "Layered",
184
+ backdrop: "Backdrop",
185
+ neurons: "Neurons",
186
+ plain: "Plain"
187
+ }
188
+ };
189
+
190
+ export { TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
191
+ //# sourceMappingURL=index.js.map
192
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @broberg/app-shell — the framework-free core of the fleet's app shell (F092).\n//\n// The shell every app in the fleet had been copying: sidebar + top bar +\n// notification bell + user menu. cardmem and trail each had one (\"ported from\n// trail\"), and helpdesk and the appkit starter would have made the third and\n// fourth. This file holds what does not depend on a framework: the navigation\n// data model, the notification contract and its sources, and the small rules\n// both the Preact and React parts must agree on.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template, not\n// an optional slot — so the contract and a default source live here, and a new\n// app gets a working notification centre by pointing one URL at its backend.\n\n// ── Navigation ─────────────────────────────────────────────────────────────\n\n/** One sidebar entry. `icon` is whatever your framework renders (an element). */\nexport interface NavItem<Icon = unknown> {\n id: string;\n label: string;\n href: string;\n icon?: Icon;\n /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */\n badge?: number;\n}\nexport interface NavGroup<Icon = unknown> {\n label: string;\n items: NavItem<Icon>[];\n}\n\n/**\n * Is this item the current page? Exact match for \"/\", otherwise the path is the\n * item or below it (\"/board/123\" is on \"/board\", \"/boardroom\" is not).\n */\nexport function isActivePath(href: string, currentPath: string): boolean {\n const path = (currentPath.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n const target = (href.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n if (target === \"/\") return path === \"/\";\n return path === target || path.startsWith(target + \"/\");\n}\n\n/** \"99+\" above 99, nothing for 0 or less. */\nexport function badgeLabel(n: number | undefined): string | null {\n if (!n || n <= 0) return null;\n return n > 99 ? \"99+\" : String(n);\n}\n\n// ── Persisted UI state ─────────────────────────────────────────────────────\n\n/** Read a boolean flag from localStorage; storage errors read as `fallback`. */\nexport function readFlag(key: string, fallback: boolean): boolean {\n try {\n const v = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n return v === null ? fallback : v === \"1\";\n } catch {\n return fallback;\n }\n}\nexport function writeFlag(key: string, value: boolean): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n /* private mode / quota: the UI still works, it just does not remember */\n }\n}\nexport function readSet(key: string): Set<string> {\n try {\n const raw = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n const v = raw ? (JSON.parse(raw) as unknown) : [];\n return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\nexport function writeSet(key: string, value: Set<string>): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, JSON.stringify([...value]));\n } catch {\n /* see writeFlag */\n }\n}\n\n// ── Avatar ─────────────────────────────────────────────────────────────────\n\n/**\n * Two initials for the avatar fallback: first letter of the first and the last\n * word, upper-cased. One word gives one letter; nothing gives \"?\".\n *\n * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar\n * is the Broberg ID `picture`, and without one, initials. Gravatar would send a\n * hash of the user's email to a US service on every render.\n */\nexport function initials(name: string | undefined, email?: string): string {\n const source = (name ?? \"\").trim() || (email ?? \"\").split(\"@\")[0] || \"\";\n const words = source.split(/[\\s._-]+/).filter(Boolean);\n if (words.length === 0) return \"?\";\n const first = words[0]![0] ?? \"\";\n const last = words.length > 1 ? words[words.length - 1]![0] ?? \"\" : \"\";\n return (first + last).toUpperCase() || \"?\";\n}\n\n// ── Notifications ──────────────────────────────────────────────────────────\n//\n// REUSE, NOT A SECOND CONTRACT. The fleet already agreed the notification shape\n// in @broberg/notifications (F074): `NotificationRow` — the same field names\n// @broberg/webpush sends, end to end — and the server core that owns the ONE\n// counting rule. The shell's bell renders that row and drives its panel with\n// that package's `createBellShell`, so a server built on `createNotifications`\n// and this bell speak the same language without an adapter.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template. So\n// a source, and a default one, ship here: point one URL at your backend.\n\nimport type { NotificationRow } from \"@broberg/notifications/types\";\nexport type { NotificationRow } from \"@broberg/notifications/types\";\n\n/** Where the bell gets its data. Swap it to point at any backend. */\nexport interface NotificationSource {\n /** The rows to show (newest first). */\n loadRows(): Promise<NotificationRow[]>;\n /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */\n countUnseen(): Promise<number>;\n /** Returns the ids that actually transitioned to seen. */\n markSeen(ids: readonly string[]): Promise<string[]>;\n markAllSeen(): Promise<string[]>;\n /** Called with a callback that re-counts; return an unsubscribe. Optional. */\n subscribe?(onChange: () => void): () => void;\n}\n\n/**\n * The default source: a URL on your own backend, shaped after\n * @broberg/notifications' server core so `createNotifications` sits right\n * behind it.\n *\n * GET {url} → { items: NotificationRow[], unseen: number }\n * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }\n * POST {url}/seen-all → { clearedIds: string[], count: number }\n *\n * It re-counts when the window regains focus or the tab becomes visible, and on\n * a timer (default 60 s). A failed GET THROWS — the bell shows «could not\n * load», never an empty list: \"could not fetch\" and \"nothing new\" must not look\n * the same.\n */\nexport function createFetchNotificationSource(opts: {\n url: string;\n fetch?: typeof fetch;\n /** Re-count interval in ms. 0 turns it off. Default 60 000. */\n pollMs?: number;\n credentials?: RequestCredentials;\n}): NotificationSource {\n const base = opts.url.replace(/\\/+$/, \"\");\n const doFetch = (input: string, init?: RequestInit) =>\n (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? \"include\", ...init });\n const get = async (): Promise<{ items: NotificationRow[]; unseen: number }> => {\n const res = await doFetch(base, { headers: { accept: \"application/json\" } });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const body = (await res.json().catch(() => null)) as { items?: unknown; unseen?: unknown } | null;\n if (!body || !Array.isArray(body.items)) throw new Error(\"response has no items array\");\n const items = body.items as NotificationRow[];\n const unseen = typeof body.unseen === \"number\" ? body.unseen : items.filter((n) => n.seenAt == null).length;\n return { items, unseen };\n };\n const post = async (path: string, body?: unknown): Promise<string[]> => {\n const res = await doFetch(`${base}${path}`, {\n method: \"POST\",\n headers: body === undefined ? undefined : { \"content-type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const r = (await res.json().catch(() => ({}))) as { clearedIds?: unknown };\n return Array.isArray(r.clearedIds) ? (r.clearedIds as string[]) : [];\n };\n return {\n loadRows: async () => (await get()).items,\n countUnseen: async () => (await get()).unseen,\n markSeen: (ids) => post(\"/seen\", { ids }),\n markAllSeen: () => post(\"/seen-all\"),\n subscribe(onChange) {\n if (typeof window === \"undefined\") return () => {};\n const onVisible = () => {\n if (document.visibilityState === \"visible\") onChange();\n };\n window.addEventListener(\"focus\", onChange);\n document.addEventListener(\"visibilitychange\", onVisible);\n const ms = opts.pollMs ?? 60_000;\n const timer = ms > 0 ? setInterval(onChange, ms) : null;\n return () => {\n window.removeEventListener(\"focus\", onChange);\n document.removeEventListener(\"visibilitychange\", onVisible);\n if (timer) clearInterval(timer);\n };\n },\n };\n}\n\n/** An in-memory source for demos and tests. */\nexport function createMemoryNotificationSource(initial: NotificationRow[] = []): NotificationSource & {\n push(n: NotificationRow): void;\n rows(): NotificationRow[];\n} {\n let rows = initial.map((n) => ({ ...n }));\n const listeners = new Set<() => void>();\n const emit = () => listeners.forEach((l) => l());\n const mark = (pred: (n: NotificationRow) => boolean): string[] => {\n const now = Date.now();\n const cleared: string[] = [];\n rows = rows.map((n) => {\n if (n.seenAt == null && pred(n)) {\n cleared.push(n.id);\n return { ...n, seenAt: now };\n }\n return n;\n });\n if (cleared.length) emit();\n return cleared;\n };\n return {\n loadRows: async () => rows.map((n) => ({ ...n })),\n countUnseen: async () => rows.filter((n) => n.seenAt == null).length,\n markSeen: async (ids) => mark((n) => ids.includes(n.id)),\n markAllSeen: async () => mark(() => true),\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n push(n) {\n rows = [n, ...rows];\n emit();\n },\n rows: () => rows.map((n) => ({ ...n })),\n };\n}\n\n/** \"nu\", \"5 min\", \"3 t\", \"i går\", \"4 d\" (da) or the English equivalents. */\nexport function relativeTime(ts: string | number, lang: \"da\" | \"en\", now = Date.now()): string {\n const ms = typeof ts === \"number\" ? ts : Date.parse(ts);\n if (!Number.isFinite(ms)) return \"\";\n const m = Math.floor((now - ms) / 60_000);\n if (m < 1) return lang === \"da\" ? \"nu\" : \"now\";\n if (m < 60) return `${m} min`;\n const h = Math.floor(m / 60);\n if (h < 24) return lang === \"da\" ? `${h} t` : `${h} h`;\n const d = Math.floor(h / 24);\n if (d === 1) return lang === \"da\" ? \"i går\" : \"yesterday\";\n return `${d} d`;\n}\n\n// ── Text ───────────────────────────────────────────────────────────────────\n\nexport const TEXT = {\n da: {\n openMenu: \"Åbn menu\",\n collapse: \"Skjul sidemenu\",\n expand: \"Vis sidemenu\",\n notifications: \"Notifikationer\",\n markAllRead: \"Markér alle læst\",\n noNotifications: \"Ingen notifikationer endnu.\",\n couldNotLoad: \"Kunne ikke hente notifikationer.\",\n seeAll: \"Vis alle\",\n close: \"Luk\",\n account: \"Konto\",\n signOut: \"Log ud\",\n theme: \"Tema\",\n light: \"Lys\",\n dark: \"Mørk\",\n system: \"System\",\n palette: \"Palet\",\n surfaces: \"Flader\",\n flat: \"Flade\",\n layered: \"Lag\",\n backdrop: \"Baggrund\",\n neurons: \"Neuroner\",\n plain: \"Ren\",\n },\n en: {\n openMenu: \"Open menu\",\n collapse: \"Hide sidebar\",\n expand: \"Show sidebar\",\n notifications: \"Notifications\",\n markAllRead: \"Mark all read\",\n noNotifications: \"No notifications yet.\",\n couldNotLoad: \"Could not load notifications.\",\n seeAll: \"See all\",\n close: \"Close\",\n account: \"Account\",\n signOut: \"Sign out\",\n theme: \"Theme\",\n light: \"Light\",\n dark: \"Dark\",\n system: \"System\",\n palette: \"Palette\",\n surfaces: \"Surfaces\",\n flat: \"Flat\",\n layered: \"Layered\",\n backdrop: \"Backdrop\",\n neurons: \"Neurons\",\n plain: \"Plain\",\n },\n} as const;\nexport type Lang = keyof typeof TEXT;\n"]}