@hgdc/ui 0.64.0 → 0.66.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.
@@ -1,3 +1,4 @@
1
+ import type { ModuleWord } from "./modules.ts";
1
2
  import { type ReactNode } from "react";
2
3
  import type { ProductView } from "./addresses.ts";
3
4
  import type { FamilyProduct } from "./family-svg.ts";
@@ -20,7 +21,7 @@ export type { AppShellAssistant } from "./shell-assistant.tsx";
20
21
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
21
22
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
22
23
  */
23
- export declare function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
24
+ export declare function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, project, }: {
24
25
  /** Le produit affiché (`"host"`) : son nom au-dessus de ses onglets, « vous êtes ici » dans la liste des modules. */
25
26
  product: FamilyProduct;
26
27
  /**
@@ -101,4 +102,13 @@ export declare function AppShell({ product, organization, productRail: productRa
101
102
  * de test), ou `false` pour ne rien lire. L'accès « État du service » y mène.
102
103
  */
103
104
  statusPage?: string | false | undefined;
105
+ /**
106
+ * Le projet choisi en haut de page (`useShellProject`) : la liste des modules se limite aux siens, et
107
+ * « Réglages du projet » paraît en bas de la barre. Absent : tous les projets.
108
+ */
109
+ project?: {
110
+ name: string;
111
+ modules: ReadonlyArray<ModuleWord>;
112
+ settingsHref: string;
113
+ } | undefined;
104
114
  }): React.JSX.Element;
package/dist/app-shell.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Settings02Icon from "@hugeicons/core-free-icons/Settings02Icon";
3
+ import { SIDEBAR_ITEM } from "./sidebar-nav.js";
2
4
  import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
3
5
  import { HugeiconsIcon } from "@hugeicons/react";
4
6
  import { Suspense, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
@@ -38,11 +40,11 @@ export { SidebarNavGroup } from "./sidebar-nav-group.js";
38
40
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
39
41
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
40
42
  */
41
- export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
43
+ export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, project, }) {
42
44
  const [menuOpen, setMenuOpen] = useState(false);
43
45
  const preference = useProductBarPreference();
44
46
  // Relu chez Account seulement par un produit passé aux deux vues (il donne `view`, ADR 0014).
45
- useProductBarSync(productRailOption === undefined && viewOption !== undefined);
47
+ useProductBarSync(productRailOption !== false);
46
48
  const productRail = productRailOption ?? (viewOption === undefined || productBarShown(viewOption, preference));
47
49
  const [navLevel, setNavLevel] = useState("tabs");
48
50
  useToggleShortcut();
@@ -100,7 +102,7 @@ export function AppShell({ product, organization, productRail: productRailOption
100
102
  useAssistantShortcut(assistant);
101
103
  const otherView = viewOption === undefined ? null : _jsx(OtherViewLink, { product: product, view: viewOption });
102
104
  // Dans la barre de Basalte, seul Réglages reste au-dessus du compte : le reste passe dans son menu.
103
- const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: productRail ? { ...(links?.settings ? { settings: links.settings } : {}), status: false } : (links ?? {}), statusPageUrl: statusPageUrl, otherView: productRail ? null : otherView }), account ?? sidebarFooter] }));
105
+ const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, productRail && project ? (_jsx("div", { className: "px-3 pb-1", children: _jsxs("a", { href: project.settingsHref, className: SIDEBAR_ITEM, children: [_jsx(HugeiconsIcon, { icon: Settings02Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: "R\u00E9glages du projet" })] }) })) : null, _jsx(ShellCommonLinks, { links: productRail ? { ...(links?.settings ? { settings: links.settings } : {}), status: false } : (links ?? {}), statusPageUrl: statusPageUrl, otherView: productRail ? null : otherView }), account ?? sidebarFooter] }));
104
106
  const menuLinks = productRail
105
107
  ? {
106
108
  documentation: links?.documentation,
@@ -108,6 +110,6 @@ export function AppShell({ product, organization, productRail: productRailOption
108
110
  otherView: viewOption === undefined ? undefined : otherViewMenuLink(product, viewOption),
109
111
  }
110
112
  : {};
111
- const navigation = productRail ? (_jsx(ShellNavigation, { product: product, nav: nav, accountHref: accountHref, level: navLevel, onLevelChange: setNavLevel })) : (nav);
113
+ const navigation = productRail ? (_jsx(ShellNavigation, { product: product, nav: nav, accountHref: accountHref, level: navLevel, onLevelChange: setNavLevel, projectModules: project?.modules })) : (nav);
112
114
  return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false), product }, children: _jsx(ModuleBarShownContext, { value: productRail, children: _jsx(ShellMenuLinksContext, { value: menuLinks, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-module-bar": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: "min-h-dvh lg:grid lg:h-dvh lg:grid-cols-[auto_minmax(0,1fr)_auto] lg:bg-shell", children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), _jsxs("aside", { ref: sidebarRef, "data-slot": "app-shell-sidebar", "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [productRail ? (_jsx(ShellOrganizationHead, { organization: organization })) : (_jsxs(_Fragment, { children: [_jsx(SidebarHead, { product: product }), organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null] })), navigation, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { "data-slot": "app-shell-header", className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: familyProductName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null, assistant ? (_jsx(AssistantButton, { assistant: assistant, panelId: assistantPanelId, buttonRef: assistantButtonRef })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), assistant && assistantMounted ? (_jsx(AssistantPanel, { assistant: assistant, panelId: assistantPanelId, wide: wide, openAtShellMount: assistantOpenAtMount, buttonRef: assistantButtonRef })) : null, hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, navigation, _jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)] empty:hidden", children: footer })] }) })] }) }) }) }) }) }));
113
115
  }
@@ -1,9 +1,11 @@
1
1
  import { type ModuleWord } from "./modules.ts";
2
2
  /**
3
- * L'ordre et le choix des modules de la barre, propres à chaque personne. Sous `app.hgdc.ch`, toutes
4
- * les consoles partagent la même origine, donc le même réglage.
3
+ * L'ordre et le choix des modules de la barre, propres à chaque personne : Account les garde
4
+ * (`product-bar.ts`), et chaque origine en retient la dernière valeur, pour s'afficher sans attendre.
5
5
  */
6
6
  export declare const MODULE_BAR_KEY = "hgdc-shell:modules";
7
7
  export declare function saveModuleBar(order: ReadonlyArray<ModuleWord>): void;
8
+ /** L'ordre retenu dans cette origine, lu hors de React. */
9
+ export declare function storedModuleBar(): ReadonlyArray<ModuleWord>;
8
10
  /** L'ordre retenu ; celui par défaut au rendu serveur. */
9
11
  export declare function useModuleBar(): ReadonlyArray<ModuleWord>;
@@ -1,8 +1,8 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import { DEFAULT_MODULE_BAR, parseModuleBar } from "./modules.js";
3
3
  /**
4
- * L'ordre et le choix des modules de la barre, propres à chaque personne. Sous `app.hgdc.ch`, toutes
5
- * les consoles partagent la même origine, donc le même réglage.
4
+ * L'ordre et le choix des modules de la barre, propres à chaque personne : Account les garde
5
+ * (`product-bar.ts`), et chaque origine en retient la dernière valeur, pour s'afficher sans attendre.
6
6
  */
7
7
  export const MODULE_BAR_KEY = "hgdc-shell:modules";
8
8
  const EVENT = "hgdc-module-bar";
@@ -50,6 +50,10 @@ export function saveModuleBar(order) {
50
50
  }
51
51
  window.dispatchEvent(new Event(EVENT));
52
52
  }
53
+ /** L'ordre retenu dans cette origine, lu hors de React. */
54
+ export function storedModuleBar() {
55
+ return read();
56
+ }
53
57
  /** L'ordre retenu ; celui par défaut au rendu serveur. */
54
58
  export function useModuleBar() {
55
59
  return useSyncExternalStore(subscribe, read, () => DEFAULT_MODULE_BAR);
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { FamilyProduct } from "./family-svg.ts";
3
+ import { type ModuleWord } from "./modules.ts";
3
4
  /** Ce que montre le milieu de la barre : les onglets du module ouvert, la liste des modules, ou son réglage. */
4
5
  export type ShellNavLevel = "tabs" | "modules" | "edit";
5
6
  /**
@@ -7,10 +8,12 @@ export type ShellNavLevel = "tabs" | "modules" | "edit";
7
8
  * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
8
9
  * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
9
10
  */
10
- export declare function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }: {
11
+ export declare function ShellNavigation({ product, nav, accountHref, level, onLevelChange, projectModules, }: {
11
12
  product: FamilyProduct;
12
13
  nav: ReactNode;
13
14
  accountHref?: string | undefined;
14
15
  level: ShellNavLevel;
15
16
  onLevelChange: (level: ShellNavLevel) => void;
17
+ /** Modules activés sur le projet choisi : la liste s'y limite. Absent : tous les projets. */
18
+ projectModules?: ReadonlyArray<ModuleWord> | undefined;
16
19
  }): React.JSX.Element;
@@ -11,6 +11,7 @@ import { useState, useSyncExternalStore } from "react";
11
11
  import { Button } from "./button.js";
12
12
  import { cn } from "./cn.js";
13
13
  import { saveModuleBar, useModuleBar } from "./module-bar-store.js";
14
+ import { pushModuleBar } from "./product-bar.js";
14
15
  import { hiddenModules, moduleByWord, moveModule, shownModules } from "./modules.js";
15
16
  import { ProductBadge } from "./product-badge.js";
16
17
  import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
@@ -80,12 +81,13 @@ function ModuleBarEditor({ order, onSave, onCancel, }) {
80
81
  * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
81
82
  * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
82
83
  */
83
- export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }) {
84
+ export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, projectModules, }) {
84
85
  const pageOrigin = usePageOrigin();
85
86
  const order = useModuleBar();
86
87
  const { products, account } = shellProductEntries({ current: product, accountHref, pageOrigin });
87
- return (_jsxs("div", { "data-slot": "shell-navigation", className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { "data-slot": "shell-manager", className: "shrink-0 px-3 pt-1", children: [_jsx("ul", { children: _jsx(ShellEntryLink, { entry: account, icon: UserGroupIcon }) }), _jsx("div", { "aria-hidden": true, className: "mx-2 mt-2 h-px bg-foreground/8" })] }), level === "modules" ? (_jsx(ModuleList, { entries: products, order: shownModules(order, product), onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
88
+ return (_jsxs("div", { "data-slot": "shell-navigation", className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { "data-slot": "shell-manager", className: "shrink-0 px-3 pt-1", children: [_jsx("ul", { children: _jsx(ShellEntryLink, { entry: account, icon: UserGroupIcon }) }), _jsx("div", { "aria-hidden": true, className: "mx-2 mt-2 h-px bg-foreground/8" })] }), level === "modules" ? (_jsx(ModuleList, { entries: products, order: shownModules(order, product).filter((word) => !projectModules || word === product || projectModules.includes(word)), onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
88
89
  saveModuleBar(next);
90
+ void pushModuleBar(next);
89
91
  onLevelChange("modules");
90
92
  }, onCancel: () => onLevelChange("modules") })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "shrink-0 px-3 pt-2", children: [_jsx("ul", { children: _jsx(SidebarNavBack, { label: "Retour aux modules", keepMenuOpen: true, render: _jsx("button", { type: "button", className: "w-full", onClick: () => onLevelChange("modules") }) }) }), _jsxs("div", { "data-slot": "shell-module-title", className: "flex min-h-9 items-center gap-2.5 px-3 text-sm font-semibold lg:text-shell-foreground lg:rail:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "truncate font-heading", children: familyProductName(product) })] })] }), nav] }))] }));
91
93
  }
@@ -1,4 +1,5 @@
1
1
  import type { ProductView } from "./addresses.ts";
2
+ import { type ModuleWord } from "./modules.ts";
2
3
  /**
3
4
  * Réglage « Barre des produits » (ADR 0014 de la famille), choisi dans Account :
4
5
  * - `auto` : la barre sous `app.hgdc.ch`, pas sur `<mot>.hgdc.ch` ;
@@ -13,7 +14,7 @@ export type ProductBarPreference = "auto" | "always" | "never";
13
14
  export declare const PRODUCT_BAR_PREFERENCES: ReadonlyArray<ProductBarPreference>;
14
15
  /** Libellés du réglage, dans l'ordre d'affichage. */
15
16
  export declare const PRODUCT_BAR_LABELS: Readonly<Record<ProductBarPreference, string>>;
16
- /** Lecture du réglage chez Account : `{ productBar }`, rien d'autre. */
17
+ /** Réglages chez Account : `{ productBar, moduleBar }`, rien d'autre. */
17
18
  export declare const ACCOUNT_PREFERENCES_URL = "https://account.hgdc.ch/api/preferences";
18
19
  /** Valeur reçue (Account, stockage) : un réglage connu, sinon `auto`. */
19
20
  export declare function parseProductBar(value: unknown): ProductBarPreference;
@@ -25,5 +26,11 @@ export declare function rememberProductBar(preference: ProductBarPreference): vo
25
26
  export declare function useProductBarPreference(): ProductBarPreference;
26
27
  /** Relit le réglage chez Account une fois par chargement de page ; une erreur garde la dernière valeur. */
27
28
  export declare function syncProductBar(fetchImpl?: typeof fetch): Promise<void>;
29
+ /**
30
+ * Enregistre la barre des modules chez Account, pour la retrouver sur un autre appareil. Un échec n'est
31
+ * pas signalé : la barre reste réglée dans cette origine, et repart au prochain chargement.
32
+ * `keepalive` : un clic sur un autre module recharge la page, l'envoi doit y survivre.
33
+ */
34
+ export declare function pushModuleBar(order: ReadonlyArray<ModuleWord>, fetchImpl?: typeof fetch): Promise<void>;
28
35
  /** À monter une fois (la coque le fait) : relecture du réglage après l'hydratation. */
29
36
  export declare function useProductBarSync(enabled?: boolean): void;
@@ -1,4 +1,6 @@
1
1
  import { useEffect, useSyncExternalStore } from "react";
2
+ import { saveModuleBar, storedModuleBar } from "./module-bar-store.js";
3
+ import { parseModuleBar } from "./modules.js";
2
4
  export const PRODUCT_BAR_PREFERENCES = ["auto", "always", "never"];
3
5
  /** Libellés du réglage, dans l'ordre d'affichage. */
4
6
  export const PRODUCT_BAR_LABELS = {
@@ -6,10 +8,12 @@ export const PRODUCT_BAR_LABELS = {
6
8
  always: "Toujours",
7
9
  never: "Jamais",
8
10
  };
9
- /** Lecture du réglage chez Account : `{ productBar }`, rien d'autre. */
11
+ /** Réglages chez Account : `{ productBar, moduleBar }`, rien d'autre. */
10
12
  export const ACCOUNT_PREFERENCES_URL = "https://account.hgdc.ch/api/preferences";
11
13
  const STORAGE_KEY = "hgdc-product-bar";
12
14
  const EVENT = "hgdc-product-bar";
15
+ /** Présent tant qu'Account n'a pas confirmé la barre des modules réglée ici : elle ne doit pas être écrasée. */
16
+ const MODULE_BAR_UNSENT_KEY = "hgdc-shell:modules-unsent";
13
17
  /** Valeur reçue (Account, stockage) : un réglage connu, sinon `auto`. */
14
18
  export function parseProductBar(value) {
15
19
  return PRODUCT_BAR_PREFERENCES.includes(value) ? value : "auto";
@@ -62,13 +66,69 @@ export async function syncProductBar(fetchImpl = fetch) {
62
66
  if (!response.ok)
63
67
  return;
64
68
  const body = await response.json();
65
- if (typeof body === "object" && body !== null && "productBar" in body)
69
+ if (typeof body !== "object" || body === null)
70
+ return;
71
+ if ("productBar" in body)
66
72
  rememberProductBar(parseProductBar(body.productBar));
73
+ if (moduleBarUnsent()) {
74
+ await pushModuleBar(storedModuleBar(), fetchImpl);
75
+ return;
76
+ }
77
+ // Absente : la personne n'a encore rien réglé, cette origine garde ce qu'elle a.
78
+ if ("moduleBar" in body) {
79
+ const order = parseModuleBar(body.moduleBar);
80
+ if (order)
81
+ saveModuleBar(order);
82
+ }
67
83
  }
68
84
  catch {
69
85
  // Account injoignable ou hors ligne : rien ne change.
70
86
  }
71
87
  }
88
+ function moduleBarUnsent() {
89
+ try {
90
+ return window.localStorage.getItem(MODULE_BAR_UNSENT_KEY) !== null;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function markModuleBarUnsent(unsent) {
97
+ try {
98
+ if (unsent)
99
+ window.localStorage.setItem(MODULE_BAR_UNSENT_KEY, "1");
100
+ else
101
+ window.localStorage.removeItem(MODULE_BAR_UNSENT_KEY);
102
+ }
103
+ catch {
104
+ // Stockage bloqué : la prochaine relecture reprendra la valeur d'Account.
105
+ }
106
+ }
107
+ /**
108
+ * Enregistre la barre des modules chez Account, pour la retrouver sur un autre appareil. Un échec n'est
109
+ * pas signalé : la barre reste réglée dans cette origine, et repart au prochain chargement.
110
+ * `keepalive` : un clic sur un autre module recharge la page, l'envoi doit y survivre.
111
+ */
112
+ export async function pushModuleBar(order, fetchImpl = fetch) {
113
+ // Account n'accepte que les consoles de `hgdc.ch` : en local, en test, rien à envoyer.
114
+ if (!window.location.hostname.endsWith(".hgdc.ch"))
115
+ return;
116
+ markModuleBarUnsent(true);
117
+ try {
118
+ const response = await fetchImpl(ACCOUNT_PREFERENCES_URL, {
119
+ method: "PUT",
120
+ credentials: "include",
121
+ keepalive: true,
122
+ headers: { "content-type": "application/json" },
123
+ body: JSON.stringify({ moduleBar: order }),
124
+ });
125
+ if (response.ok)
126
+ markModuleBarUnsent(false);
127
+ }
128
+ catch {
129
+ // Account injoignable ou hors ligne.
130
+ }
131
+ }
72
132
  let synced = false;
73
133
  /** À monter une fois (la coque le fait) : relecture du réglage après l'hydratation. */
74
134
  export function useProductBarSync(enabled = true) {
@@ -0,0 +1,30 @@
1
+ import type { ModuleWord } from "./modules.ts";
2
+ export declare function saveShellProject(organizationId: string, projectId: string | null): void;
3
+ /** L'identifiant du projet choisi ; `null` : tous les projets, et toujours au rendu serveur. */
4
+ export declare function useShellProject(organizationId: string | null | undefined): string | null;
5
+ /** Un projet commun, tel que le rend `listCoreProjects` de `@hgdc/auth/core`. */
6
+ export type ShellCoreProject = {
7
+ id: string;
8
+ name: string;
9
+ modules: ReadonlyArray<string>;
10
+ };
11
+ /** Le projet choisi, s'il existe encore et si le module y est activé. Sinon : tous les projets. */
12
+ export declare function activeCoreProject<Project extends ShellCoreProject>(projects: ReadonlyArray<Project> | undefined, activeId: string | null): Project | null;
13
+ /** Ce que `project` d'`AppShell` attend pour le projet choisi. */
14
+ export declare function shellProjectFrom(project: ShellCoreProject | null, settingsHref: string): {
15
+ name: string;
16
+ modules: ReadonlyArray<ModuleWord>;
17
+ settingsHref: string;
18
+ } | undefined;
19
+ /**
20
+ * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
21
+ * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
22
+ * tant que l'organisation n'a aucun projet.
23
+ */
24
+ export declare function CoreProjectSwitcher({ organizationId, projects, createHref, onChange, }: {
25
+ organizationId: string;
26
+ projects: ReadonlyArray<ShellCoreProject> | undefined;
27
+ /** « Nouveau projet » : la page Projets de Manager. */
28
+ createHref?: string;
29
+ onChange?: (projectId: string | null) => void;
30
+ }): React.JSX.Element | null;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { ProjectSwitcher } from "./project-switcher.js";
4
+ /**
5
+ * Le projet choisi en haut de page, le même dans toutes les consoles de Basalte : sous `app.hgdc.ch`,
6
+ * elles partagent l'origine, donc ce réglage. Une valeur par organisation : changer d'organisation
7
+ * ne garde pas le projet d'une autre. Propre à chaque personne et à chaque navigateur.
8
+ */
9
+ const keyOf = (organizationId) => `hgdc-shell:project:${organizationId}`;
10
+ const EVENT = "hgdc-shell-project";
11
+ function subscribe(onChange) {
12
+ window.addEventListener(EVENT, onChange);
13
+ window.addEventListener("storage", onChange);
14
+ return () => {
15
+ window.removeEventListener(EVENT, onChange);
16
+ window.removeEventListener("storage", onChange);
17
+ };
18
+ }
19
+ export function saveShellProject(organizationId, projectId) {
20
+ try {
21
+ if (projectId === null)
22
+ window.localStorage.removeItem(keyOf(organizationId));
23
+ else
24
+ window.localStorage.setItem(keyOf(organizationId), projectId);
25
+ }
26
+ catch {
27
+ // Stockage bloqué : le choix vaut seulement pour cette page.
28
+ }
29
+ window.dispatchEvent(new Event(EVENT));
30
+ }
31
+ /** L'identifiant du projet choisi ; `null` : tous les projets, et toujours au rendu serveur. */
32
+ export function useShellProject(organizationId) {
33
+ return useSyncExternalStore(subscribe, () => {
34
+ if (!organizationId)
35
+ return null;
36
+ try {
37
+ return window.localStorage.getItem(keyOf(organizationId));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }, () => null);
43
+ }
44
+ /** Le projet choisi, s'il existe encore et si le module y est activé. Sinon : tous les projets. */
45
+ export function activeCoreProject(projects, activeId) {
46
+ return projects?.find((project) => project.id === activeId) ?? null;
47
+ }
48
+ /** Ce que `project` d'`AppShell` attend pour le projet choisi. */
49
+ export function shellProjectFrom(project, settingsHref) {
50
+ if (!project)
51
+ return undefined;
52
+ return { name: project.name, modules: project.modules, settingsHref };
53
+ }
54
+ /**
55
+ * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
56
+ * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
57
+ * tant que l'organisation n'a aucun projet.
58
+ */
59
+ export function CoreProjectSwitcher({ organizationId, projects, createHref, onChange, }) {
60
+ const activeId = useShellProject(organizationId);
61
+ if (projects !== undefined && projects.length === 0)
62
+ return null;
63
+ const known = activeCoreProject(projects, activeId);
64
+ const entries = projects?.map(({ id, name }) => ({ id, name }));
65
+ return (_jsx(ProjectSwitcher, { projects: entries, activeId: known?.id ?? null, ...(createHref ? { createHref } : {}), onSelect: (id) => {
66
+ saveShellProject(organizationId, id);
67
+ onChange?.(id);
68
+ } }));
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",