@hgdc/ui 0.66.2 → 0.68.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,35 @@
1
+ /**
2
+ * Fichier image envoyé ou téléchargé, lu dans ses octets et sans I/O : le type annoncé ne prouve rien.
3
+ * Un SVG n'y figure jamais : ouvert seul, il exécuterait ses scripts sur l'origine qui le sert.
4
+ */
5
+ /** Type réel d'une image, d'après ses premiers octets ; `null` si ce n'est ni PNG, ni JPEG, ni WebP. */
6
+ export declare function imageKind(head: Uint8Array): "png" | "jpeg" | "webp" | null;
7
+ export type ImageKind = NonNullable<ReturnType<typeof imageKind>>;
8
+ export declare const IMAGE_MIME: Record<ImageKind, string>;
9
+ /** Dimensions d'un PNG, lues dans son en-tête `IHDR` ; `null` si l'en-tête manque. */
10
+ export declare function pngSize(bytes: Uint8Array): {
11
+ width: number;
12
+ height: number;
13
+ } | null;
14
+ /** Dimensions d'un JPEG, lues dans son premier segment SOF ; `null` si aucun n'est trouvé. */
15
+ export declare function jpegSize(bytes: Uint8Array): {
16
+ width: number;
17
+ height: number;
18
+ } | null;
19
+ /** Dimensions d'un WebP (VP8, VP8L ou VP8X) ; `null` si l'en-tête manque. */
20
+ export declare function webpSize(bytes: Uint8Array): {
21
+ width: number;
22
+ height: number;
23
+ } | null;
24
+ /** Côté le plus long accepté pour une image téléversée : au-delà, rien ne sert et le décodage coûte. */
25
+ export declare const MAX_IMAGE_SIDE = 4096;
26
+ export declare function imageSize(kind: ImageKind, bytes: Uint8Array): {
27
+ width: number;
28
+ height: number;
29
+ } | null;
30
+ /**
31
+ * JPEG sans ses métadonnées : EXIF et XMP (APP1, dont la position GPS), IPTC (APP13), commentaires
32
+ * et autres segments d'application sont retirés, l'image elle-même n'est pas touchée (pas de
33
+ * réencodage). Un fichier mal formé est rendu tel quel : `imageProblem` l'a déjà contrôlé.
34
+ */
35
+ export declare function stripJpegMetadata(bytes: Uint8Array): Uint8Array;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Fichier image envoyé ou téléchargé, lu dans ses octets et sans I/O : le type annoncé ne prouve rien.
3
+ * Un SVG n'y figure jamais : ouvert seul, il exécuterait ses scripts sur l'origine qui le sert.
4
+ */
5
+ /** Type réel d'une image, d'après ses premiers octets ; `null` si ce n'est ni PNG, ni JPEG, ni WebP. */
6
+ export function imageKind(head) {
7
+ const at = (i) => head[i] ?? -1;
8
+ if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => at(i) === b))
9
+ return "png";
10
+ if (at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff)
11
+ return "jpeg";
12
+ const ascii = (from, to) => String.fromCharCode(...head.slice(from, to));
13
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
14
+ return "webp";
15
+ return null;
16
+ }
17
+ export const IMAGE_MIME = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
18
+ /** Dimensions d'un PNG, lues dans son en-tête `IHDR` ; `null` si l'en-tête manque. */
19
+ export function pngSize(bytes) {
20
+ if (bytes.length < 24 || String.fromCharCode(...bytes.slice(12, 16)) !== "IHDR")
21
+ return null;
22
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
23
+ return { width: view.getUint32(16), height: view.getUint32(20) };
24
+ }
25
+ /** Dimensions d'un JPEG, lues dans son premier segment SOF ; `null` si aucun n'est trouvé. */
26
+ export function jpegSize(bytes) {
27
+ let i = 2;
28
+ while (i + 9 < bytes.length) {
29
+ if (bytes[i] !== 0xff)
30
+ return null;
31
+ const marker = bytes[i + 1] ?? 0;
32
+ if (marker === 0xff) {
33
+ i++;
34
+ continue;
35
+ }
36
+ // Marqueurs sans longueur : début d'image, redémarrages.
37
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
38
+ i += 2;
39
+ continue;
40
+ }
41
+ const sof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
42
+ if (sof)
43
+ return {
44
+ height: ((bytes[i + 5] ?? 0) << 8) | (bytes[i + 6] ?? 0),
45
+ width: ((bytes[i + 7] ?? 0) << 8) | (bytes[i + 8] ?? 0),
46
+ };
47
+ i += 2 + (((bytes[i + 2] ?? 0) << 8) | (bytes[i + 3] ?? 0));
48
+ }
49
+ return null;
50
+ }
51
+ /** Dimensions d'un WebP (VP8, VP8L ou VP8X) ; `null` si l'en-tête manque. */
52
+ export function webpSize(bytes) {
53
+ if (bytes.length < 30)
54
+ return null;
55
+ const b = (i) => bytes[i] ?? 0;
56
+ const chunk = String.fromCharCode(...bytes.slice(12, 16));
57
+ if (chunk === "VP8 ")
58
+ return { width: (b(26) | (b(27) << 8)) & 0x3fff, height: (b(28) | (b(29) << 8)) & 0x3fff };
59
+ if (chunk === "VP8L")
60
+ return {
61
+ width: 1 + (((b(22) & 0x3f) << 8) | b(21)),
62
+ height: 1 + (((b(24) & 0x0f) << 10) | (b(23) << 2) | ((b(22) & 0xc0) >> 6)),
63
+ };
64
+ if (chunk === "VP8X")
65
+ return { width: 1 + (b(24) | (b(25) << 8) | (b(26) << 16)), height: 1 + (b(27) | (b(28) << 8) | (b(29) << 16)) };
66
+ return null;
67
+ }
68
+ /** Côté le plus long accepté pour une image téléversée : au-delà, rien ne sert et le décodage coûte. */
69
+ export const MAX_IMAGE_SIDE = 4096;
70
+ export function imageSize(kind, bytes) {
71
+ return kind === "png" ? pngSize(bytes) : kind === "jpeg" ? jpegSize(bytes) : webpSize(bytes);
72
+ }
73
+ /** Segments JPEG gardés : JFIF (APP0), profil de couleur (APP2), Adobe (APP14, conversion des couleurs). */
74
+ const KEPT_APP = new Set([0xe0, 0xe2, 0xee]);
75
+ /**
76
+ * JPEG sans ses métadonnées : EXIF et XMP (APP1, dont la position GPS), IPTC (APP13), commentaires
77
+ * et autres segments d'application sont retirés, l'image elle-même n'est pas touchée (pas de
78
+ * réencodage). Un fichier mal formé est rendu tel quel : `imageProblem` l'a déjà contrôlé.
79
+ */
80
+ export function stripJpegMetadata(bytes) {
81
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8)
82
+ return bytes;
83
+ const parts = [bytes.slice(0, 2)];
84
+ let i = 2;
85
+ while (i + 4 <= bytes.length) {
86
+ if (bytes[i] !== 0xff)
87
+ return bytes;
88
+ const marker = bytes[i + 1] ?? 0;
89
+ // Début des données compressées : tout le reste est l'image.
90
+ if (marker === 0xda) {
91
+ parts.push(bytes.slice(i));
92
+ break;
93
+ }
94
+ const length = ((bytes[i + 2] ?? 0) << 8) | (bytes[i + 3] ?? 0);
95
+ const end = i + 2 + length;
96
+ if (length < 2 || end > bytes.length)
97
+ return bytes;
98
+ const metadata = (marker >= 0xe1 && marker <= 0xef && !KEPT_APP.has(marker)) || marker === 0xfe;
99
+ if (!metadata)
100
+ parts.push(bytes.slice(i, end));
101
+ i = end;
102
+ }
103
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
104
+ let at = 0;
105
+ for (const part of parts) {
106
+ out.set(part, at);
107
+ at += part.length;
108
+ }
109
+ return out;
110
+ }
@@ -4,7 +4,7 @@ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
4
4
  import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
5
5
  import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { Menu as MenuPrimitive } from "@base-ui/react/menu";
7
- import { useEffect } from "react";
7
+ import { useEffect, useState } from "react";
8
8
  import { Badge } from "./badge.js";
9
9
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
10
10
  import { AVATAR_TONE_CLASSES, initials, orgTone } from "./initials.js";
@@ -30,8 +30,10 @@ export const MENU_LINK_ITEM = "relative flex cursor-default items-center gap-2.5
30
30
  * teinte du nom.
31
31
  */
32
32
  export function OrgMark({ org, className, }) {
33
- if (org.logo)
34
- return _jsx("img", { src: org.logo, alt: "", className: cn("size-8 shrink-0 rounded-lg object-cover", className) });
33
+ // Image retirée ou serveur muet : les initiales, plutôt qu'une image cassée.
34
+ const [broken, setBroken] = useState(null);
35
+ if (org.logo && org.logo !== broken)
36
+ return (_jsx("img", { src: org.logo, alt: "", onError: () => setBroken(org.logo ?? null), className: cn("size-8 shrink-0 rounded-lg bg-muted object-contain", className) }));
35
37
  return (_jsx("span", { "aria-hidden": true, className: cn("grid size-8 shrink-0 place-items-center rounded-lg text-xs font-semibold uppercase", AVATAR_TONE_CLASSES[orgTone(org)], className), children: initials(org.name) }));
36
38
  }
37
39
  /**
@@ -22,6 +22,8 @@ export function ProjectMark({ project, className, }) {
22
22
  const mark = (project.mark ?? project.name.trim().charAt(0)) || "?";
23
23
  return (_jsx("span", { "aria-hidden": true, "data-slot": "project-mark", className: cn(base, "font-mono text-[10px] font-semibold text-foreground uppercase", className), children: mark }));
24
24
  }
25
+ const startsWithVowel = (word) => /^[aeiouyàâéèêh]/i.test(word);
26
+ const changeLabel = (noun) => `changer ${startsWithVowel(noun) ? "d'" : "de "}${noun}`;
25
27
  /** Rend une destination du menu : un lien (`href`, par `renderLink`) ou une action. */
26
28
  function SwitcherEntry({ href, onSelect, renderLink, children, }) {
27
29
  if (href !== undefined)
@@ -39,12 +41,14 @@ function SwitcherEntry({ href, onSelect, renderLink, children, }) {
39
41
  * pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
40
42
  * reste utilisable pour « Tous les projets » et « Nouveau projet ».
41
43
  */
42
- export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun = { one: "projet", many: "projets" }, createLabel = `${noun.feminine ? "Nouvelle" : "Nouveau"} ${noun.one}`, className, }) {
44
+ export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun = { one: "projet", many: "projets" }, createLabel = `${noun.feminine ? "Nouvelle" : startsWithVowel(noun.one) ? "Nouvel" : "Nouveau"} ${noun.one}`, className, }) {
43
45
  const capitalized = (word) => word.charAt(0).toUpperCase() + word.slice(1);
44
46
  const all = `${noun.feminine ? "Toutes" : "Tous"} les ${noun.many}`;
45
47
  const hasActive = activeId !== undefined && activeId !== null;
46
48
  const current = hasActive ? projects?.find((p) => p.id === activeId) : undefined;
47
49
  const label = hasActive ? (current?.name ?? activeName ?? "") : all;
48
50
  const canCreate = createHref !== undefined || onCreate !== undefined;
49
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `${capitalized(noun.one)} ${label}, changer de ${noun.one}` : `${all}, changer de ${noun.one}`, className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: all }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: capitalized(noun.many) }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": `${capitalized(noun.one)} ${noun.feminine ? "active" : "actif"}`, className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
51
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive
52
+ ? `${capitalized(noun.one)} ${label}, ${changeLabel(noun.one)}`
53
+ : `${all}, ${changeLabel(noun.one)}`, className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: all }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: capitalized(noun.many) }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": `${capitalized(noun.one)} ${noun.feminine ? "active" : "actif"}`, className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
50
54
  }
@@ -16,6 +16,30 @@ export declare function shellProjectFrom(project: ShellCoreProject | null, setti
16
16
  modules: ReadonlyArray<ModuleWord>;
17
17
  settingsHref: string;
18
18
  } | undefined;
19
+ /**
20
+ * Un module montre un seul sélecteur en haut de page : celui de Basalte dès que l'organisation a des
21
+ * projets, ou le sien sinon (démo, espace pas encore relié). Pendant le chargement, celui de Basalte.
22
+ */
23
+ export declare function coreSwitcherShown(organizationId: string | null | undefined, projects: ReadonlyArray<ShellCoreProject> | undefined): boolean;
24
+ /** Ce qu'un module a pour un projet : un site, une doc, un assistant, une page de statut. */
25
+ export type ProjectResource = {
26
+ coreProjectId?: string | null | undefined;
27
+ };
28
+ /**
29
+ * Où mène un projet choisi dans le sélecteur : sa ressource s'il n'y en a qu'une, sinon la liste du
30
+ * module, déjà filtrée. `null` : on ne bouge pas (liste en chargement, ou déjà sur la liste).
31
+ */
32
+ export declare function projectDestination<Resource extends ProjectResource>(resources: ReadonlyArray<Resource> | undefined, projectId: string | null, resourceOpen: boolean): {
33
+ to: "resource";
34
+ resource: Resource;
35
+ } | {
36
+ to: "list";
37
+ } | null;
38
+ /**
39
+ * Une ressource ouverte par son adresse (lien, palette, liste) : le sélecteur montre son projet. Écrit
40
+ * à l'ouverture seulement : revenir sur « Tous les projets » n'est pas annulé avant de quitter la page.
41
+ */
42
+ export declare function useShellProjectOfResource(organizationId: string | null | undefined, coreProjectId: string | null | undefined): void;
19
43
  /**
20
44
  * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
21
45
  * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useSyncExternalStore } from "react";
2
+ import { useEffect, useSyncExternalStore } from "react";
3
3
  import { ProjectSwitcher } from "./project-switcher.js";
4
4
  /**
5
5
  * Le projet choisi en haut de page, le même dans toutes les consoles de Basalte : sous `app.hgdc.ch`,
@@ -51,6 +51,37 @@ export function shellProjectFrom(project, settingsHref) {
51
51
  return undefined;
52
52
  return { name: project.name, modules: project.modules, settingsHref };
53
53
  }
54
+ /**
55
+ * Un module montre un seul sélecteur en haut de page : celui de Basalte dès que l'organisation a des
56
+ * projets, ou le sien sinon (démo, espace pas encore relié). Pendant le chargement, celui de Basalte.
57
+ */
58
+ export function coreSwitcherShown(organizationId, projects) {
59
+ return Boolean(organizationId) && projects?.length !== 0;
60
+ }
61
+ /**
62
+ * Où mène un projet choisi dans le sélecteur : sa ressource s'il n'y en a qu'une, sinon la liste du
63
+ * module, déjà filtrée. `null` : on ne bouge pas (liste en chargement, ou déjà sur la liste).
64
+ */
65
+ export function projectDestination(resources, projectId, resourceOpen) {
66
+ if (resources === undefined)
67
+ return null;
68
+ if (projectId !== null) {
69
+ const matching = resources.filter((resource) => resource.coreProjectId === projectId);
70
+ if (matching.length === 1 && matching[0])
71
+ return { to: "resource", resource: matching[0] };
72
+ }
73
+ return resourceOpen ? { to: "list" } : null;
74
+ }
75
+ /**
76
+ * Une ressource ouverte par son adresse (lien, palette, liste) : le sélecteur montre son projet. Écrit
77
+ * à l'ouverture seulement : revenir sur « Tous les projets » n'est pas annulé avant de quitter la page.
78
+ */
79
+ export function useShellProjectOfResource(organizationId, coreProjectId) {
80
+ useEffect(() => {
81
+ if (organizationId && coreProjectId)
82
+ saveShellProject(organizationId, coreProjectId);
83
+ }, [organizationId, coreProjectId]);
84
+ }
54
85
  /**
55
86
  * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
56
87
  * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.66.2",
3
+ "version": "0.68.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",