@hgdc/ui 0.2.1 → 0.4.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.
Files changed (71) hide show
  1. package/README.md +78 -21
  2. package/dist/account-menu.d.ts +32 -0
  3. package/dist/account-menu.js +39 -0
  4. package/dist/alert.d.ts +19 -0
  5. package/dist/alert.js +25 -0
  6. package/dist/app-shell.d.ts +56 -0
  7. package/dist/app-shell.js +107 -0
  8. package/dist/card.d.ts +24 -0
  9. package/dist/card.js +42 -0
  10. package/dist/chart-path.d.ts +9 -0
  11. package/dist/chart-path.js +46 -0
  12. package/dist/checkbox.d.ts +12 -0
  13. package/dist/checkbox.js +19 -0
  14. package/dist/command-menu.d.ts +56 -0
  15. package/dist/command-menu.js +79 -0
  16. package/dist/copy-button.d.ts +26 -0
  17. package/dist/copy-button.js +46 -0
  18. package/dist/data-table.d.ts +70 -0
  19. package/dist/data-table.js +46 -0
  20. package/dist/destructive-dialog.d.ts +28 -0
  21. package/dist/destructive-dialog.js +74 -0
  22. package/dist/dns-record-table.d.ts +30 -0
  23. package/dist/dns-record-table.js +33 -0
  24. package/dist/error-card.d.ts +23 -0
  25. package/dist/error-card.js +35 -0
  26. package/dist/filter-bar.d.ts +50 -0
  27. package/dist/filter-bar.js +88 -0
  28. package/dist/kbd.d.ts +9 -0
  29. package/dist/kbd.js +19 -0
  30. package/dist/log-viewer.d.ts +39 -0
  31. package/dist/log-viewer.js +114 -0
  32. package/dist/middle-truncate.d.ts +17 -0
  33. package/dist/middle-truncate.js +25 -0
  34. package/dist/org-switcher.d.ts +32 -0
  35. package/dist/org-switcher.js +32 -0
  36. package/dist/page-header.d.ts +27 -0
  37. package/dist/page-header.js +31 -0
  38. package/dist/popover.d.ts +13 -0
  39. package/dist/popover.js +22 -0
  40. package/dist/radio-group.d.ts +16 -0
  41. package/dist/radio-group.js +22 -0
  42. package/dist/relative-time.d.ts +16 -0
  43. package/dist/relative-time.js +54 -0
  44. package/dist/resource-list.d.ts +31 -0
  45. package/dist/resource-list.js +35 -0
  46. package/dist/secret-reveal.d.ts +13 -0
  47. package/dist/secret-reveal.js +26 -0
  48. package/dist/skeleton.d.ts +28 -0
  49. package/dist/skeleton.js +47 -0
  50. package/dist/sonner.d.ts +20 -1
  51. package/dist/sonner.js +43 -2
  52. package/dist/sparkline.d.ts +21 -0
  53. package/dist/sparkline.js +28 -0
  54. package/dist/stat.d.ts +30 -0
  55. package/dist/stat.js +26 -0
  56. package/dist/status.d.ts +22 -0
  57. package/dist/status.js +37 -0
  58. package/dist/table.d.ts +50 -0
  59. package/dist/table.js +68 -0
  60. package/dist/tabs.d.ts +16 -0
  61. package/dist/tabs.js +26 -0
  62. package/dist/time-chart.d.ts +40 -0
  63. package/dist/time-chart.js +90 -0
  64. package/dist/timeline.d.ts +21 -0
  65. package/dist/timeline.js +11 -0
  66. package/dist/tooltip.d.ts +15 -0
  67. package/dist/tooltip.js +25 -0
  68. package/dist/uptime-bar.d.ts +29 -0
  69. package/dist/uptime-bar.js +57 -0
  70. package/package.json +1 -1
  71. package/styles.css +45 -2
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon";
3
+ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
4
+ import Search01Icon from "@hugeicons/core-free-icons/Search01Icon";
5
+ import { HugeiconsIcon } from "@hugeicons/react";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { Button, buttonVariants } from "./button.js";
8
+ import { cn } from "./cn.js";
9
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
10
+ import { Input } from "./input.js";
11
+ /** Le texte de la pastille : « Statut », « Statut : Erreur », « Statut : Erreur +2 ». */
12
+ export function filterSummary(filter, selected = []) {
13
+ const labels = filter.options.filter((o) => selected.includes(o.value)).map((o) => o.label);
14
+ const first = labels[0];
15
+ if (first === undefined)
16
+ return filter.label;
17
+ return labels.length === 1 ? `${filter.label} : ${first}` : `${filter.label} : ${first} +${labels.length - 1}`;
18
+ }
19
+ /** Nombre de critères posés, recherche comprise : sert à afficher « Effacer » et l'état vide. */
20
+ export function activeFilterCount(values = {}, search = "") {
21
+ const filters = Object.values(values).filter((v) => v !== undefined && v.length > 0).length;
22
+ return filters + (search.trim() === "" ? 0 : 1);
23
+ }
24
+ /** Ajoute ou retire une valeur d'un filtre à choix multiple, dans l'ordre des options. */
25
+ export function toggleFilterValue(filter, selected = [], value, checked) {
26
+ const next = new Set(selected);
27
+ if (checked)
28
+ next.add(value);
29
+ else
30
+ next.delete(value);
31
+ return filter.options.map((o) => o.value).filter((v) => next.has(v));
32
+ }
33
+ /**
34
+ * Recherche qui suit la saisie sans écrire dans l'URL à chaque touche : la valeur part après
35
+ * `delay` ms de calme. Si l'URL change de son côté (retour arrière), le champ la reprend.
36
+ */
37
+ function useDebouncedSearch(value, onChange, delay) {
38
+ const [draft, setDraft] = useState(value);
39
+ const [seen, setSeen] = useState(value);
40
+ if (value !== seen) {
41
+ setSeen(value);
42
+ setDraft(value);
43
+ }
44
+ const latest = useRef(onChange);
45
+ useEffect(() => {
46
+ latest.current = onChange;
47
+ });
48
+ useEffect(() => {
49
+ if (draft === value)
50
+ return;
51
+ const timer = setTimeout(() => latest.current(draft), delay);
52
+ return () => clearTimeout(timer);
53
+ }, [draft, value, delay]);
54
+ const flush = (next) => {
55
+ setDraft(next);
56
+ if (next !== value)
57
+ latest.current(next);
58
+ };
59
+ return { draft, setDraft, flush };
60
+ }
61
+ function SearchField({ value, onChange, placeholder = "Rechercher…", label = "Rechercher", delay = 250, }) {
62
+ const { draft, setDraft, flush } = useDebouncedSearch(value, onChange, delay);
63
+ return (_jsxs("div", { className: "relative min-w-0 flex-1 basis-full sm:basis-56 sm:max-w-sm", children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", "aria-label": label, placeholder: placeholder, autoComplete: "off", spellCheck: false, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
64
+ if (event.key === "Enter")
65
+ flush(draft);
66
+ if (event.key === "Escape" && draft !== "") {
67
+ event.preventDefault();
68
+ flush("");
69
+ }
70
+ }, className: "h-10 pr-9 pl-10 lg:h-9 [&::-webkit-search-cancel-button]:appearance-none" }), draft !== "" ? (_jsx("button", { type: "button", "aria-label": "Vider la recherche", onClick: () => flush(""), className: "absolute top-1/2 right-1.5 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5" }) })) : null] }));
71
+ }
72
+ function FilterPill({ filter, selected = [], onChange, }) {
73
+ const active = selected.length > 0;
74
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { "data-slot": "filter-pill", "data-active": active || undefined, className: cn(buttonVariants({ variant: "outline", size: "sm" }), "max-w-64 gap-1 pr-2.5 font-normal text-muted-foreground", active && "bg-foreground/[0.06] font-medium text-foreground ring-0 dark:bg-foreground/[0.08]"), children: [_jsx("span", { className: "truncate", children: filterSummary(filter, selected) }), _jsx(HugeiconsIcon, { icon: ArrowDown01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5 opacity-60" })] }), _jsx(DropdownMenuContent, { className: "w-auto", children: filter.single ? (_jsxs(DropdownMenuRadioGroup, { label: filter.label, value: selected[0] ?? "", onValueChange: (value) => onChange(value === "" ? [] : [value]), children: [_jsx(DropdownMenuRadioItem, { value: "", children: "Tous" }), filter.options.map((option) => (_jsx(DropdownMenuRadioItem, { value: option.value, children: option.label }, option.value)))] })) : (_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: filter.label }), filter.options.map((option) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(option.value), onCheckedChange: (checked) => onChange(toggleFilterValue(filter, selected, option.value, checked)), children: option.label }, option.value)))] })) })] }));
75
+ }
76
+ /**
77
+ * Recherche et filtres en pastilles au-dessus d'une liste. Entièrement contrôlée, sans routeur :
78
+ * le produit lit les valeurs dans les paramètres d'URL et les y réécrit dans les callbacks, pour
79
+ * que le partage, le rafraîchissement et le retour arrière gardent la vue.
80
+ *
81
+ * La recherche n'écrit qu'après 250 ms de calme (Entrée pour tout de suite, Échap pour vider).
82
+ * Un filtre à choix multiple reste ouvert pendant qu'on coche ; `single` en fait un choix unique
83
+ * avec « Tous ». « Effacer » apparaît dès qu'un critère est posé.
84
+ */
85
+ export function FilterBar({ search, filters = [], values = {}, onFilterChange, onReset, actions, className, }) {
86
+ const count = activeFilterCount(values, search?.value);
87
+ return (_jsxs("div", { role: "search", "data-slot": "filter-bar", className: cn("flex flex-wrap items-center gap-2", className), children: [search ? _jsx(SearchField, { ...search }) : null, filters.map((filter) => (_jsx(FilterPill, { filter: filter, ...(values[filter.key] !== undefined ? { selected: values[filter.key] } : {}), onChange: (next) => onFilterChange?.(filter.key, next) }, filter.key))), onReset && count > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onReset, className: "text-muted-foreground", children: "Effacer" })) : null, actions ? _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null] }));
88
+ }
package/dist/kbd.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Une touche de clavier : `<Kbd>Ctrl</Kbd><Kbd>K</Kbd>`. Teinte discrète, chiffres et lettres en
3
+ * largeur fixe. Dans une infobulle, elle prend l'encre inversée.
4
+ */
5
+ export declare function Kbd({ className, ...props }: React.ComponentProps<"kbd">): React.JSX.Element;
6
+ /** Plusieurs touches d'un même raccourci, espacées et lues comme un groupe. */
7
+ export declare function KbdGroup({ className, ...props }: React.ComponentProps<"span">): React.JSX.Element;
8
+ /** « ⌘ » sur Mac, « Ctrl » ailleurs. `false` au rendu serveur, puis corrigé au montage. */
9
+ export declare function isApplePlatform(): boolean;
package/dist/kbd.js ADDED
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "./cn.js";
3
+ /**
4
+ * Une touche de clavier : `<Kbd>Ctrl</Kbd><Kbd>K</Kbd>`. Teinte discrète, chiffres et lettres en
5
+ * largeur fixe. Dans une infobulle, elle prend l'encre inversée.
6
+ */
7
+ export function Kbd({ className, ...props }) {
8
+ return (_jsx("kbd", { "data-slot": "kbd", className: cn("inline-flex h-5 min-w-5 items-center justify-center rounded-md bg-foreground/[0.07] px-1 font-sans text-[11px] font-medium text-muted-foreground tabular-nums select-none", className), ...props }));
9
+ }
10
+ /** Plusieurs touches d'un même raccourci, espacées et lues comme un groupe. */
11
+ export function KbdGroup({ className, ...props }) {
12
+ return _jsx("span", { "data-slot": "kbd-group", className: cn("inline-flex items-center gap-1", className), ...props });
13
+ }
14
+ /** « ⌘ » sur Mac, « Ctrl » ailleurs. `false` au rendu serveur, puis corrigé au montage. */
15
+ export function isApplePlatform() {
16
+ if (typeof navigator === "undefined")
17
+ return false;
18
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
19
+ }
@@ -0,0 +1,39 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error";
2
+ export type LogLine = {
3
+ id: string;
4
+ /** Millisecondes ou `Date`. */
5
+ time: number | Date;
6
+ level?: LogLevel;
7
+ message: string;
8
+ };
9
+ export declare const LOG_LEVEL_LABEL: Record<LogLevel, string>;
10
+ /** Les lignes qui passent les niveaux choisis (aucun : tous) et contiennent la recherche. */
11
+ export declare function filterLogLines(lines: ReadonlyArray<LogLine>, { levels, query }: {
12
+ levels?: ReadonlyArray<LogLevel>;
13
+ query?: string;
14
+ }): ReadonlyArray<LogLine>;
15
+ /** Le texte copié : une ligne par entrée, heure, niveau, message. */
16
+ export declare function formatLogLines(lines: ReadonlyArray<LogLine>, formatTime?: (time: number | Date) => string): string;
17
+ /** Les lignes à rendre pour une position de défilement : `[début, fin)`. */
18
+ export declare function visibleRange(scrollTop: number, viewport: number, count: number): [start: number, end: number];
19
+ /**
20
+ * Journaux d'un déploiement, d'une fonction, d'un envoi : flux en chasse fixe, heure et niveau
21
+ * en tête de ligne. Filtre par niveau, recherche (surlignée), copie de ce qui est affiché.
22
+ *
23
+ * `live` : le flux grossit encore. « Suivre » garde le bas en vue ; remonter dans le journal
24
+ * l'arrête, « Suivre » ou le bouton du bas le reprend.
25
+ *
26
+ * Seules les lignes visibles sont dans la page (hauteur de ligne fixe, lignes longues défilant
27
+ * de côté) : dix mille lignes coûtent autant que cinquante.
28
+ */
29
+ export declare function LogViewer({ lines, label, live, height, formatTime, emptyLabel, className, }: {
30
+ lines: ReadonlyArray<LogLine>;
31
+ /** Nom du journal pour les lecteurs d'écran. */
32
+ label?: string;
33
+ live?: boolean;
34
+ /** Hauteur de la zone des lignes, en pixels. */
35
+ height?: number;
36
+ formatTime?: (time: number | Date) => string;
37
+ emptyLabel?: string;
38
+ className?: string;
39
+ }): React.JSX.Element;
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Toggle } from "@base-ui/react/toggle";
3
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
4
+ import ArrowDownDoubleIcon from "@hugeicons/core-free-icons/ArrowDownDoubleIcon";
5
+ import Copy01Icon from "@hugeicons/core-free-icons/Copy01Icon";
6
+ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
7
+ import { HugeiconsIcon } from "@hugeicons/react";
8
+ import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react";
9
+ import { buttonVariants } from "./button.js";
10
+ import { cn } from "./cn.js";
11
+ import { useCopy } from "./copy-button.js";
12
+ import { Input } from "./input.js";
13
+ export const LOG_LEVEL_LABEL = {
14
+ debug: "Debug",
15
+ info: "Info",
16
+ warn: "Alerte",
17
+ error: "Erreur",
18
+ };
19
+ const LEVELS = ["debug", "info", "warn", "error"];
20
+ const LEVEL_INK = {
21
+ debug: "text-muted-foreground/70",
22
+ info: "text-muted-foreground",
23
+ warn: "text-status-degraded-ink",
24
+ error: "text-destructive",
25
+ };
26
+ /** Hauteur fixe d'une ligne, en pixels : c'est elle qui permet de n'afficher que la partie visible. */
27
+ const ROW = 24;
28
+ /** Lignes rendues au-delà de la partie visible, en haut et en bas, pour un défilement sans trou. */
29
+ const OVERSCAN = 20;
30
+ const clock = new Intl.DateTimeFormat("fr-CH", {
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ second: "2-digit",
34
+ fractionalSecondDigits: 3,
35
+ });
36
+ /** Les lignes qui passent les niveaux choisis (aucun : tous) et contiennent la recherche. */
37
+ export function filterLogLines(lines, { levels = [], query = "" }) {
38
+ const needle = query.trim().toLocaleLowerCase("fr-CH");
39
+ if (levels.length === 0 && needle === "")
40
+ return lines;
41
+ return lines.filter((line) => (levels.length === 0 || (line.level !== undefined && levels.includes(line.level))) &&
42
+ (needle === "" || line.message.toLocaleLowerCase("fr-CH").includes(needle)));
43
+ }
44
+ /** Le texte copié : une ligne par entrée, heure, niveau, message. */
45
+ export function formatLogLines(lines, formatTime = (t) => clock.format(t)) {
46
+ return lines
47
+ .map((line) => [formatTime(line.time), line.level?.toUpperCase(), line.message].filter(Boolean).join(" "))
48
+ .join("\n");
49
+ }
50
+ /** Les lignes à rendre pour une position de défilement : `[début, fin)`. */
51
+ export function visibleRange(scrollTop, viewport, count) {
52
+ const start = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN);
53
+ const end = Math.min(count, Math.ceil((scrollTop + viewport) / ROW) + OVERSCAN);
54
+ return [start, end];
55
+ }
56
+ function Highlight({ text, query }) {
57
+ const needle = query.trim().toLocaleLowerCase("fr-CH");
58
+ if (needle === "")
59
+ return text;
60
+ const parts = [];
61
+ const lower = text.toLocaleLowerCase("fr-CH");
62
+ let from = 0;
63
+ let at = lower.indexOf(needle);
64
+ while (at !== -1) {
65
+ if (at > from)
66
+ parts.push(text.slice(from, at));
67
+ parts.push(_jsx("mark", { className: "rounded-sm bg-status-degraded/30 text-foreground", children: text.slice(at, at + needle.length) }, at));
68
+ from = at + needle.length;
69
+ at = lower.indexOf(needle, from);
70
+ }
71
+ parts.push(text.slice(from));
72
+ return parts;
73
+ }
74
+ const CHIP = "inline-flex h-8 items-center rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-pressed:bg-foreground data-pressed:text-background";
75
+ /**
76
+ * Journaux d'un déploiement, d'une fonction, d'un envoi : flux en chasse fixe, heure et niveau
77
+ * en tête de ligne. Filtre par niveau, recherche (surlignée), copie de ce qui est affiché.
78
+ *
79
+ * `live` : le flux grossit encore. « Suivre » garde le bas en vue ; remonter dans le journal
80
+ * l'arrête, « Suivre » ou le bouton du bas le reprend.
81
+ *
82
+ * Seules les lignes visibles sont dans la page (hauteur de ligne fixe, lignes longues défilant
83
+ * de côté) : dix mille lignes coûtent autant que cinquante.
84
+ */
85
+ /* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- zone de défilement focalisable, voulu */
86
+ export function LogViewer({ lines, label = "Journaux", live = false, height = 420, formatTime = (t) => clock.format(t), emptyLabel = "Aucune ligne pour l'instant.", className, }) {
87
+ const [query, setQuery] = useState("");
88
+ const [levels, setLevels] = useState([]);
89
+ const [follow, setFollow] = useState(true);
90
+ const [scrollTop, setScrollTop] = useState(0);
91
+ const viewport = useRef(null);
92
+ const { copied, copy } = useCopy();
93
+ const deferredQuery = useDeferredValue(query);
94
+ const shown = useMemo(() => filterLogLines(lines, { levels, query: deferredQuery }), [lines, levels, deferredQuery]);
95
+ const hasLevels = useMemo(() => lines.some((l) => l.level !== undefined), [lines]);
96
+ const following = live && follow;
97
+ const [start, end] = visibleRange(scrollTop, height, shown.length);
98
+ useLayoutEffect(() => {
99
+ const el = viewport.current;
100
+ if (!following || !el)
101
+ return;
102
+ el.scrollTop = el.scrollHeight;
103
+ }, [following, shown]);
104
+ function onScroll() {
105
+ const el = viewport.current;
106
+ if (!el)
107
+ return;
108
+ setScrollTop(el.scrollTop);
109
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= ROW;
110
+ if (live && follow && !atBottom)
111
+ setFollow(false);
112
+ }
113
+ return (_jsxs("div", { "data-slot": "log-viewer", className: cn("surface flex min-w-0 flex-col overflow-hidden rounded-3xl", className), children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-3", children: [_jsx(Input, { type: "search", "aria-label": "Rechercher dans les journaux", placeholder: "Rechercher\u2026", autoComplete: "off", spellCheck: false, value: query, onChange: (event) => setQuery(event.target.value), className: "h-9 min-w-0 flex-1 basis-full sm:max-w-xs sm:basis-48 lg:h-9" }), hasLevels ? (_jsx(ToggleGroup, { multiple: true, "aria-label": "Niveaux", value: levels, onValueChange: (value) => setLevels(LEVELS.filter((l) => value.includes(l))), className: "flex items-center gap-1", children: LEVELS.map((level) => (_jsx(Toggle, { value: level, className: CHIP, children: LOG_LEVEL_LABEL[level] }, level))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [live ? (_jsx(Toggle, { pressed: follow, onPressedChange: setFollow, className: CHIP, children: "Suivre" })) : null, _jsx("button", { type: "button", "aria-label": copied ? "Copié" : "Copier les lignes affichées", title: copied ? "Copié" : "Copier les lignes affichées", disabled: shown.length === 0, onClick: () => void copy(formatLogLines(shown, formatTime)), className: cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground"), children: _jsx(HugeiconsIcon, { icon: copied ? Tick02Icon : Copy01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4 pop-in" }, copied ? "ok" : "copy") })] })] }), _jsxs("div", { className: "relative", children: [_jsx("div", { ref: viewport, role: "log", "aria-label": label, "aria-live": "off", tabIndex: 0, onScroll: onScroll, style: { height }, className: "overflow-auto border-t border-foreground/5 py-2 font-mono text-[13px] leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", children: shown.length === 0 ? (_jsx("p", { className: "px-4 font-sans text-sm text-muted-foreground", children: lines.length === 0 ? emptyLabel : "Aucune ligne ne correspond." })) : (_jsx("div", { style: { height: shown.length * ROW }, className: "relative w-max min-w-full", children: _jsx("div", { style: { transform: `translateY(${start * ROW}px)` }, children: shown.slice(start, end).map((line) => (_jsxs("div", { "data-slot": "log-line", "data-level": line.level, style: { height: ROW }, className: cn("flex gap-3 px-4 whitespace-pre hover:bg-foreground/[0.03]", line.level === "error" && "bg-destructive/[0.05]"), children: [_jsx("time", { className: "shrink-0 text-muted-foreground tabular-nums select-none", children: formatTime(line.time) }), hasLevels ? (_jsx("span", { className: cn("w-14 shrink-0 uppercase", line.level && LEVEL_INK[line.level]), children: line.level ? LOG_LEVEL_LABEL[line.level] : "" })) : null, _jsx("span", { children: _jsx(Highlight, { text: line.message, query: deferredQuery }) })] }, line.id))) }) })) }), live && !follow ? (_jsxs("button", { type: "button", onClick: () => setFollow(true), className: cn(buttonVariants({ variant: "secondary", size: "xs" }), "absolute bottom-3 left-1/2 -translate-x-1/2 shadow-lg shadow-black/10"), children: [_jsx(HugeiconsIcon, { icon: ArrowDownDoubleIcon, strokeWidth: 2, "aria-hidden": true }), "Revenir en bas"] })) : null] }), _jsx("p", { className: "sr-only", "aria-live": "polite", children: deferredQuery.trim() !== "" || levels.length > 0 ? `${shown.length} lignes sur ${lines.length}` : "" })] }));
114
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Où couper : la fin gardée entière. Un chemin garde son dernier segment (`…/src/app.tsx`), un
3
+ * identifiant ses `tail` derniers caractères (`dpl_8f3a…c91e`). La fin d'un identifiant est celle
4
+ * qu'on compare d'un coup d'œil, comme dans un hash git.
5
+ */
6
+ export declare function splitForTruncation(text: string, tail?: number): [start: string, end: string];
7
+ /**
8
+ * Un texte long coupé au milieu quand la place manque : le début s'efface en « … », la fin reste
9
+ * lisible. Le texte entier reste dans la page (lecteurs d'écran, sélection, copie, recherche du
10
+ * navigateur) et dans l'infobulle native. Pur CSS : aucune mesure, la coupe suit la largeur.
11
+ */
12
+ export declare function MiddleTruncate({ text, tail, className, }: {
13
+ text: string;
14
+ /** Caractères gardés à la fin quand le texte n'est pas un chemin. */
15
+ tail?: number;
16
+ className?: string;
17
+ }): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "./cn.js";
3
+ /**
4
+ * Où couper : la fin gardée entière. Un chemin garde son dernier segment (`…/src/app.tsx`), un
5
+ * identifiant ses `tail` derniers caractères (`dpl_8f3a…c91e`). La fin d'un identifiant est celle
6
+ * qu'on compare d'un coup d'œil, comme dans un hash git.
7
+ */
8
+ export function splitForTruncation(text, tail = 8) {
9
+ if (text.length <= tail * 2)
10
+ return [text, ""];
11
+ const slash = text.lastIndexOf("/", text.length - 2);
12
+ const segment = slash > 0 ? text.length - slash : 0;
13
+ // Un dernier segment trop long ou trop court ne sert pas de fin : on revient aux caractères.
14
+ const keep = segment > 2 && segment <= tail * 3 ? segment : tail;
15
+ return [text.slice(0, text.length - keep), text.slice(text.length - keep)];
16
+ }
17
+ /**
18
+ * Un texte long coupé au milieu quand la place manque : le début s'efface en « … », la fin reste
19
+ * lisible. Le texte entier reste dans la page (lecteurs d'écran, sélection, copie, recherche du
20
+ * navigateur) et dans l'infobulle native. Pur CSS : aucune mesure, la coupe suit la largeur.
21
+ */
22
+ export function MiddleTruncate({ text, tail = 8, className, }) {
23
+ const [start, end] = splitForTruncation(text, tail);
24
+ return (_jsxs("span", { "data-slot": "middle-truncate", title: text, className: cn("inline-flex max-w-full min-w-0", className), children: [_jsx("span", { className: "min-w-0 truncate", children: start }), end ? _jsx("span", { className: "shrink-0 whitespace-pre", children: end }) : null] }));
25
+ }
@@ -0,0 +1,32 @@
1
+ /** Les trois rôles d'une organisation de la famille. */
2
+ export type OrgRole = "owner" | "admin" | "member";
3
+ export declare const ORG_ROLE_LABEL: Record<OrgRole, string>;
4
+ export type Organization = {
5
+ id: string;
6
+ name: string;
7
+ role?: OrgRole;
8
+ /** URL d'un logo carré ; sans logo, les deux premières lettres du nom. */
9
+ logo?: string;
10
+ };
11
+ /** Un lien dans un menu, à l'allure d'un `DropdownMenuItem`. */
12
+ export declare const MENU_LINK_ITEM: string;
13
+ /** Marque d'une organisation : son logo, ou ses deux premières lettres sur un carré arrondi. */
14
+ export declare function OrgMark({ org, className, }: {
15
+ org: Pick<Organization, "name" | "logo">;
16
+ className?: string;
17
+ }): React.JSX.Element;
18
+ /**
19
+ * Haut de la barre latérale : l'organisation active, et le menu pour en changer ou en créer une.
20
+ * Présentationnel : le produit passe la liste, l'active et ce qu'il faut faire au choix (changer
21
+ * l'URL `/o/<slug>/…`), le kit n'appelle rien.
22
+ */
23
+ export declare function OrgSwitcher({ organizations, activeId, onSelect, createHref, onCreate, className, }: {
24
+ organizations: Array<Organization>;
25
+ activeId: string;
26
+ onSelect: (id: string) => void;
27
+ /** Lien « Créer une organisation ». */
28
+ createHref?: string;
29
+ /** Ou une action, si la création s'ouvre dans une boîte de dialogue. */
30
+ onCreate?: () => void;
31
+ className?: string;
32
+ }): React.JSX.Element | null;
@@ -0,0 +1,32 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import PlusSignIcon from "@hugeicons/core-free-icons/PlusSignIcon";
3
+ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
4
+ import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
5
+ import { HugeiconsIcon } from "@hugeicons/react";
6
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
8
+ import { cn } from "./cn.js";
9
+ export const ORG_ROLE_LABEL = {
10
+ owner: "Propriétaire",
11
+ admin: "Admin",
12
+ member: "Membre",
13
+ };
14
+ /** Un lien dans un menu, à l'allure d'un `DropdownMenuItem`. */
15
+ export const MENU_LINK_ITEM = "relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-highlighted:bg-muted max-lg:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
16
+ /** Marque d'une organisation : son logo, ou ses deux premières lettres sur un carré arrondi. */
17
+ export function OrgMark({ org, className, }) {
18
+ if (org.logo)
19
+ return _jsx("img", { src: org.logo, alt: "", className: cn("size-8 shrink-0 rounded-lg object-cover", className) });
20
+ return (_jsx("span", { "aria-hidden": true, className: cn("grid size-8 shrink-0 place-items-center rounded-lg bg-foreground text-xs font-semibold text-background uppercase", className), children: org.name.slice(0, 2) }));
21
+ }
22
+ /**
23
+ * Haut de la barre latérale : l'organisation active, et le menu pour en changer ou en créer une.
24
+ * Présentationnel : le produit passe la liste, l'active et ce qu'il faut faire au choix (changer
25
+ * l'URL `/o/<slug>/…`), le kit n'appelle rien.
26
+ */
27
+ export function OrgSwitcher({ organizations, activeId, onSelect, createHref, onCreate, className, }) {
28
+ const active = organizations.find((org) => org.id === activeId);
29
+ if (!active)
30
+ return null;
31
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": `Organisation ${active.name}, changer d'organisation`, className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-xl px-1.5 text-left 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", "lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:w-auto lg:rail:justify-center lg:rail:px-1", className) }), children: [_jsx(OrgMark, { org: active }), _jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: active.name }), active.role ? (_jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: ORG_ROLE_LABEL[active.role] })) : null] }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground lg:text-shell-muted lg:rail:hidden" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-64", children: [_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Organisations" }), organizations.map((org) => (_jsxs(DropdownMenuItem, { onClick: () => onSelect(org.id), children: [_jsx(OrgMark, { org: org, className: "size-6 rounded-md bg-muted text-[10px] text-foreground" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: org.name }), org.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Organisation active", className: "size-4" })) : org.role ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: ORG_ROLE_LABEL[org.role] })) : null] }, org.id)))] }), createHref !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(MenuPrimitive.LinkItem, { href: createHref, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : onCreate !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onCreate, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : null] })] }));
32
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export type Crumb = {
3
+ label: string;
4
+ /** Lien simple. Pour le routeur du produit, passer `render={<Link to="…" />}`. */
5
+ href?: string;
6
+ render?: ReactElement;
7
+ };
8
+ /**
9
+ * Fil d'Ariane court : organisation, projet, ressource. Le dernier élément est la page ouverte,
10
+ * en texte simple et `aria-current="page"`.
11
+ */
12
+ export declare function Breadcrumbs({ items, className }: {
13
+ items: Array<Crumb>;
14
+ className?: string;
15
+ }): React.JSX.Element;
16
+ /**
17
+ * En-tête d'une page : fil d'Ariane, titre, description, actions à droite. Une seule action
18
+ * primaire (`Button` par défaut), les autres dans un menu « … » (`DropdownMenu`). Sous `sm`, les
19
+ * actions passent sous le titre.
20
+ */
21
+ export declare function PageHeader({ title, description, actions, breadcrumbs, className, }: {
22
+ title: ReactNode;
23
+ description?: ReactNode;
24
+ actions?: ReactNode;
25
+ breadcrumbs?: Array<Crumb>;
26
+ className?: string;
27
+ }): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
3
+ import { HugeiconsIcon } from "@hugeicons/react";
4
+ import { useRender } from "@base-ui/react/use-render";
5
+ import { cn } from "./cn.js";
6
+ const crumbLink = "rounded-md text-muted-foreground transition-colors duration-(--duration-fast) ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
7
+ function CrumbLink({ crumb }) {
8
+ return useRender({
9
+ defaultTagName: "a",
10
+ render: crumb.render,
11
+ props: { href: crumb.href, className: crumbLink, children: crumb.label },
12
+ });
13
+ }
14
+ /**
15
+ * Fil d'Ariane court : organisation, projet, ressource. Le dernier élément est la page ouverte,
16
+ * en texte simple et `aria-current="page"`.
17
+ */
18
+ export function Breadcrumbs({ items, className }) {
19
+ return (_jsx("nav", { "aria-label": "Fil d'Ariane", className: cn("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 flex-wrap items-center gap-1 text-sm", children: items.map((crumb, index) => {
20
+ const last = index === items.length - 1;
21
+ return (_jsxs("li", { className: "flex min-w-0 items-center gap-1", children: [last || (crumb.href === undefined && crumb.render === undefined) ? (_jsx("span", { "aria-current": last ? "page" : undefined, className: cn("truncate", last ? "text-foreground" : "text-muted-foreground"), children: crumb.label })) : (_jsx(CrumbLink, { crumb: crumb })), last ? null : (_jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5 shrink-0 text-muted-foreground/60" }))] }, `${index}-${crumb.label}`));
22
+ }) }) }));
23
+ }
24
+ /**
25
+ * En-tête d'une page : fil d'Ariane, titre, description, actions à droite. Une seule action
26
+ * primaire (`Button` par défaut), les autres dans un menu « … » (`DropdownMenu`). Sous `sm`, les
27
+ * actions passent sous le titre.
28
+ */
29
+ export function PageHeader({ title, description, actions, breadcrumbs, className, }) {
30
+ return (_jsxs("header", { "data-slot": "page-header", className: cn("mb-8 grid gap-3", className), children: [breadcrumbs?.length ? _jsx(Breadcrumbs, { items: breadcrumbs }) : null, _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight sm:text-[28px]", children: title }), description ? _jsx("p", { className: "mt-1.5 max-w-prose text-sm text-muted-foreground", children: description }) : null] }), actions ? _jsx("div", { className: "flex flex-wrap items-center gap-2", children: actions }) : null] })] }));
31
+ }
@@ -0,0 +1,13 @@
1
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
2
+ /**
3
+ * Bulle ouverte par un clic : une explication (« Qu'est-ce qu'un domaine vérifié ? »), un petit
4
+ * formulaire, un filtre. Elle garde le focus, se ferme par Échap ou un clic à côté, et rend le
5
+ * focus à son déclencheur. Même surface que les menus (`floating.ts`).
6
+ */
7
+ declare const Popover: typeof PopoverPrimitive.Root;
8
+ declare const PopoverTrigger: typeof PopoverPrimitive.Trigger;
9
+ declare const PopoverClose: typeof PopoverPrimitive.Close;
10
+ declare function PopoverContent({ className, side, sideOffset, align, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "side" | "sideOffset" | "align">): React.JSX.Element;
11
+ declare function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props): React.JSX.Element;
12
+ declare function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props): React.JSX.Element;
13
+ export { Popover, PopoverTrigger, PopoverClose, PopoverContent, PopoverTitle, PopoverDescription };
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
3
+ import { FLOATING_COLLISION_AVOIDANCE, FLOATING_COLLISION_PADDING, FLOATING_MAX_WIDTH, FLOATING_SURFACE, } from "./floating.js";
4
+ import { cn } from "./cn.js";
5
+ /**
6
+ * Bulle ouverte par un clic : une explication (« Qu'est-ce qu'un domaine vérifié ? »), un petit
7
+ * formulaire, un filtre. Elle garde le focus, se ferme par Échap ou un clic à côté, et rend le
8
+ * focus à son déclencheur. Même surface que les menus (`floating.ts`).
9
+ */
10
+ const Popover = PopoverPrimitive.Root;
11
+ const PopoverTrigger = PopoverPrimitive.Trigger;
12
+ const PopoverClose = PopoverPrimitive.Close;
13
+ function PopoverContent({ className, side = "bottom", sideOffset = 6, align = "center", ...props }) {
14
+ return (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Positioner, { className: "isolate z-50 outline-none", side: side, sideOffset: sideOffset, align: align, collisionPadding: FLOATING_COLLISION_PADDING, collisionAvoidance: FLOATING_COLLISION_AVOIDANCE, children: _jsx(PopoverPrimitive.Popup, { "data-slot": "popover-content", className: cn(FLOATING_MAX_WIDTH, FLOATING_SURFACE, "grid w-72 origin-(--transform-origin) gap-2 rounded-2xl p-4 text-sm outline-none", "transition-[scale,opacity] duration-(--duration-fast) ease-ios data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-(--duration-instant) data-starting-style:scale-[0.98] data-starting-style:opacity-0", className), ...props }) }) }));
15
+ }
16
+ function PopoverTitle({ className, ...props }) {
17
+ return _jsx(PopoverPrimitive.Title, { "data-slot": "popover-title", className: cn("font-medium", className), ...props });
18
+ }
19
+ function PopoverDescription({ className, ...props }) {
20
+ return (_jsx(PopoverPrimitive.Description, { "data-slot": "popover-description", className: cn("text-muted-foreground", className), ...props }));
21
+ }
22
+ export { Popover, PopoverTrigger, PopoverClose, PopoverContent, PopoverTitle, PopoverDescription };
@@ -0,0 +1,16 @@
1
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio";
2
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
3
+ /**
4
+ * Groupe de boutons radio : un seul choix parmi peu d'options, toutes visibles. Les flèches
5
+ * passent d'une option à l'autre, Tab sort du groupe. Le groupe se nomme par
6
+ * `aria-labelledby` (un titre) ou un `Fieldset`.
7
+ *
8
+ * ```tsx
9
+ * <RadioGroup aria-labelledby="role" defaultValue="member">
10
+ * <label className="flex items-center gap-2.5 text-sm"><RadioGroupItem value="admin" /> Admin</label>
11
+ * <label className="flex items-center gap-2.5 text-sm"><RadioGroupItem value="member" /> Membre</label>
12
+ * </RadioGroup>
13
+ * ```
14
+ */
15
+ export declare function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props): React.JSX.Element;
16
+ export declare function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props): React.JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio";
3
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
4
+ import { cn } from "./cn.js";
5
+ /**
6
+ * Groupe de boutons radio : un seul choix parmi peu d'options, toutes visibles. Les flèches
7
+ * passent d'une option à l'autre, Tab sort du groupe. Le groupe se nomme par
8
+ * `aria-labelledby` (un titre) ou un `Fieldset`.
9
+ *
10
+ * ```tsx
11
+ * <RadioGroup aria-labelledby="role" defaultValue="member">
12
+ * <label className="flex items-center gap-2.5 text-sm"><RadioGroupItem value="admin" /> Admin</label>
13
+ * <label className="flex items-center gap-2.5 text-sm"><RadioGroupItem value="member" /> Membre</label>
14
+ * </RadioGroup>
15
+ * ```
16
+ */
17
+ export function RadioGroup({ className, ...props }) {
18
+ return _jsx(RadioGroupPrimitive, { "data-slot": "radio-group", className: cn("grid gap-3", className), ...props });
19
+ }
20
+ export function RadioGroupItem({ className, ...props }) {
21
+ return (_jsx(RadioPrimitive.Root, { "data-slot": "radio-group-item", className: cn("touch-target grid size-5 shrink-0 place-items-center rounded-full border border-foreground/25 bg-background", "transition-[border-color,box-shadow] duration-(--duration-fast) ease-out outline-none", "hover:border-foreground/40 focus-visible:ring-3 focus-visible:ring-ring/50", "data-checked:border-primary data-checked:bg-primary", "data-disabled:cursor-not-allowed data-disabled:opacity-50", "aria-invalid:border-destructive data-invalid:border-destructive", "dark:bg-white/[0.04]", className), ...props, children: _jsx(RadioPrimitive.Indicator, { className: "size-2 rounded-full bg-primary-foreground data-unchecked:hidden" }) }));
22
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * « à l'instant », « il y a 3 min », « il y a 2 h », « hier », « il y a 4 j », puis la date
3
+ * (« 3 oct. », avec l'année si ce n'est pas celle de `now`). Le futur se dit « dans 5 min ».
4
+ */
5
+ export declare function formatRelative(date: number | Date, now?: number): string;
6
+ /** Date complète, pour l'infobulle : « jeudi 15 octobre 2026 à 14:32:05 ». */
7
+ export declare function formatFullDate(date: number | Date): string;
8
+ /**
9
+ * Une date relative qui se met à jour seule (toutes les 30 s), la date exacte au survol.
10
+ * `now` fixe l'instant de référence (tests, listes qui partagent une horloge).
11
+ */
12
+ export declare function RelativeTime({ date, now, className, }: {
13
+ date: number | Date;
14
+ now?: number;
15
+ className?: string;
16
+ }): React.JSX.Element;
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
4
+ import { cn } from "./cn.js";
5
+ const LOCALE = "fr-CH";
6
+ const relative = new Intl.RelativeTimeFormat(LOCALE, { numeric: "auto", style: "short" });
7
+ const dayMonth = new Intl.DateTimeFormat(LOCALE, { day: "numeric", month: "short" });
8
+ const dayMonthYear = new Intl.DateTimeFormat(LOCALE, { day: "numeric", month: "short", year: "numeric" });
9
+ const full = new Intl.DateTimeFormat(LOCALE, { dateStyle: "full", timeStyle: "medium" });
10
+ const MINUTE = 60_000;
11
+ const HOUR = 60 * MINUTE;
12
+ const DAY = 24 * HOUR;
13
+ /**
14
+ * « à l'instant », « il y a 3 min », « il y a 2 h », « hier », « il y a 4 j », puis la date
15
+ * (« 3 oct. », avec l'année si ce n'est pas celle de `now`). Le futur se dit « dans 5 min ».
16
+ */
17
+ export function formatRelative(date, now = Date.now()) {
18
+ const ms = typeof date === "number" ? date : date.getTime();
19
+ const diff = ms - now;
20
+ const abs = Math.abs(diff);
21
+ if (abs < 45_000)
22
+ return "à l'instant";
23
+ if (abs < HOUR)
24
+ return relative.format(Math.round(diff / MINUTE), "minute");
25
+ if (abs < DAY)
26
+ return relative.format(Math.round(diff / HOUR), "hour");
27
+ if (abs < 7 * DAY)
28
+ return relative.format(Math.round(diff / DAY), "day");
29
+ const d = new Date(ms);
30
+ return new Date(now).getFullYear() === d.getFullYear() ? dayMonth.format(d) : dayMonthYear.format(d);
31
+ }
32
+ /** Date complète, pour l'infobulle : « jeudi 15 octobre 2026 à 14:32:05 ». */
33
+ export function formatFullDate(date) {
34
+ return full.format(typeof date === "number" ? new Date(date) : date);
35
+ }
36
+ function useNow(enabled) {
37
+ const [now, setNow] = useState(() => Date.now());
38
+ useEffect(() => {
39
+ if (!enabled)
40
+ return;
41
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
42
+ return () => clearInterval(timer);
43
+ }, [enabled]);
44
+ return now;
45
+ }
46
+ /**
47
+ * Une date relative qui se met à jour seule (toutes les 30 s), la date exacte au survol.
48
+ * `now` fixe l'instant de référence (tests, listes qui partagent une horloge).
49
+ */
50
+ export function RelativeTime({ date, now, className, }) {
51
+ const ticking = useNow(now === undefined);
52
+ const ms = typeof date === "number" ? date : date.getTime();
53
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("time", { dateTime: new Date(ms).toISOString(), suppressHydrationWarning: true, className: cn("tabular-nums", className) }), children: formatRelative(ms, now ?? ticking) }), _jsx(TooltipContent, { children: formatFullDate(ms) })] }));
54
+ }