@nightkatana/kronosys-app 1.0.0-beta.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 (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ import type { Lang } from "@/lib/dashboardCopy";
7
+
8
+ /**
9
+ * Premier écran obligatoire : section française (texte + bouton), séparation horizontale, section anglaise idem.
10
+ */
11
+ export function DashboardLangGateModal({
12
+ open,
13
+ onSelect,
14
+ }: {
15
+ open: boolean;
16
+ onSelect: (lang: Lang) => void;
17
+ }) {
18
+ const frBtnRef = useRef<HTMLButtonElement>(null);
19
+
20
+ useEffect(() => {
21
+ if (!open) {
22
+ return;
23
+ }
24
+ const t = globalThis.setTimeout(() => frBtnRef.current?.focus(), 50);
25
+ return () => globalThis.clearTimeout(t);
26
+ }, [open]);
27
+
28
+ if (!open || typeof document === "undefined") {
29
+ return null;
30
+ }
31
+
32
+ const btnClass =
33
+ "flex min-h-12 w-full max-w-sm items-center justify-center rounded-xl border-2 border-zinc-300 bg-white px-4 text-base font-semibold text-zinc-900 shadow-sm transition hover:border-violet-500 hover:bg-violet-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:border-violet-400 dark:hover:bg-violet-950/40";
34
+
35
+ return createPortal(
36
+ <div
37
+ className="fixed inset-0 z-[220] flex items-center justify-center bg-gradient-to-b from-zinc-900/75 via-black/60 to-zinc-900/80 p-4 backdrop-blur-[3px]"
38
+ role="dialog"
39
+ aria-modal="true"
40
+ aria-labelledby="kronosys-lang-gate-brand kronosys-lang-gate-title"
41
+ aria-describedby="kronosys-lang-gate-desc"
42
+ >
43
+ <div className="w-full max-w-lg rounded-2xl border border-violet-200/80 bg-white p-6 shadow-2xl ring-1 ring-violet-500/10 dark:border-violet-900/40 dark:bg-zinc-900 dark:ring-violet-400/10 sm:p-8">
44
+ <p
45
+ id="kronosys-lang-gate-brand"
46
+ className="text-center text-3xl font-semibold tracking-tight text-violet-700 dark:text-violet-300"
47
+ >
48
+ Kronosys
49
+ </p>
50
+ <h1
51
+ id="kronosys-lang-gate-title"
52
+ className="mt-3 text-center text-lg font-semibold leading-snug text-zinc-900 dark:text-zinc-50"
53
+ >
54
+ Bienvenue<span className="mx-2 text-zinc-300 dark:text-zinc-600" aria-hidden>·</span>Welcome
55
+ </h1>
56
+ <p className="mt-2 text-center text-xs font-medium text-zinc-500 dark:text-zinc-400">
57
+ Choisissez votre langue · Choose your language
58
+ </p>
59
+
60
+ <div id="kronosys-lang-gate-desc" className="mt-6 flex flex-col">
61
+ {/* Section française — au-dessus */}
62
+ <section
63
+ lang="fr"
64
+ aria-labelledby="kronosys-lang-gate-fr-heading"
65
+ className="flex flex-col items-center gap-4 pb-6"
66
+ >
67
+ <h2
68
+ id="kronosys-lang-gate-fr-heading"
69
+ className="text-center text-sm font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-200"
70
+ >
71
+ Français
72
+ </h2>
73
+ <p className="text-center text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
74
+ Nous sommes ravis de vous accueillir. Merci de choisir la langue souhaitée pour le tableau de bord et la
75
+ visite guidée — cela ne prend qu’un instant, puis vous pourrez poursuivre.
76
+ </p>
77
+ <p className="text-center text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
78
+ Toutes les légendes et la visite suivent ce choix. Vous pourrez le modifier plus tard via le menu langue
79
+ dans l’en-tête.
80
+ </p>
81
+ <button ref={frBtnRef} type="button" className={btnClass} onClick={() => onSelect("fr")}>
82
+ Continuer en français
83
+ </button>
84
+ </section>
85
+
86
+ {/* Séparation horizontale */}
87
+ <hr className="m-0 border-0 border-t border-zinc-200 dark:border-zinc-600" />
88
+
89
+ {/* Section anglaise — en dessous */}
90
+ <section
91
+ lang="en"
92
+ aria-labelledby="kronosys-lang-gate-en-heading"
93
+ className="flex flex-col items-center gap-4 pt-6"
94
+ >
95
+ <h2
96
+ id="kronosys-lang-gate-en-heading"
97
+ className="text-center text-sm font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-200"
98
+ >
99
+ English
100
+ </h2>
101
+ <p className="text-center text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
102
+ We are glad you are here. Thank you for choosing the language you want for the dashboard and the guided
103
+ tour — it only takes a moment, then you can continue.
104
+ </p>
105
+ <p className="text-center text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
106
+ All captions and the tour follow this choice. You can change it later from the language menu in the
107
+ header.
108
+ </p>
109
+ <button type="button" className={btnClass} onClick={() => onSelect("en")}>
110
+ Continue in English
111
+ </button>
112
+ </section>
113
+ </div>
114
+ </div>
115
+ </div>,
116
+ document.body
117
+ );
118
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { Loader2 } from "lucide-react";
4
+
5
+ /**
6
+ * Voile plein écran avec spinner circulaire (premier chargement du payload API).
7
+ */
8
+ export function DashboardLoadingOverlay({
9
+ ariaLabel,
10
+ message,
11
+ hydrationTolerant,
12
+ }: {
13
+ ariaLabel: string;
14
+ /** Absent : réserve l’espace du libellé sans afficher de texte (évite un flash dans une autre langue). */
15
+ message?: string;
16
+ /** Réduit les avertissements d’hydratation quand le SSR et le client diffèrent (ex. fallback Suspense). */
17
+ hydrationTolerant?: boolean;
18
+ }) {
19
+ return (
20
+ <div
21
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-zinc-200/55 backdrop-blur-sm dark:bg-black/45"
22
+ role="status"
23
+ aria-live="polite"
24
+ aria-busy="true"
25
+ aria-label={ariaLabel}
26
+ suppressHydrationWarning={hydrationTolerant === true}
27
+ >
28
+ <div className="flex min-h-[10.5rem] max-w-sm flex-col items-center justify-center gap-4 rounded-2xl border border-zinc-200/90 bg-white/95 px-10 py-9 shadow-2xl dark:border-zinc-600/60 dark:bg-zinc-900/95">
29
+ <Loader2
30
+ className="size-12 shrink-0 animate-spin text-violet-600 dark:text-violet-400"
31
+ strokeWidth={2}
32
+ aria-hidden
33
+ />
34
+ {message !== undefined ? (
35
+ <p className="min-h-[2.75rem] text-center text-sm leading-snug text-zinc-600 dark:text-zinc-300">{message}</p>
36
+ ) : (
37
+ <div className="min-h-[2.75rem] shrink-0" aria-hidden />
38
+ )}
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,337 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useState, type ReactNode } from "react";
4
+ import { tbModalDanger, tbModalPrimary } from "@/lib/translucentButtonClasses";
5
+
6
+ function useEscapeClose(open: boolean, onClose: () => void) {
7
+ useEffect(() => {
8
+ if (!open) {
9
+ return;
10
+ }
11
+ const onKey = (e: KeyboardEvent) => {
12
+ if (e.key === "Escape") {
13
+ onClose();
14
+ }
15
+ };
16
+ document.addEventListener("keydown", onKey);
17
+ return () => document.removeEventListener("keydown", onKey);
18
+ }, [open, onClose]);
19
+ }
20
+
21
+ /** Message seul, bouton OK (remplace `alert()`). */
22
+ export function DashboardAlertModal({
23
+ open,
24
+ title,
25
+ message,
26
+ okLabel,
27
+ onClose,
28
+ }: {
29
+ open: boolean;
30
+ title?: string;
31
+ message: string;
32
+ okLabel: string;
33
+ onClose: () => void;
34
+ }) {
35
+ const titleId = useId();
36
+ const messageId = useId();
37
+ useEscapeClose(open, onClose);
38
+
39
+ if (!open) {
40
+ return null;
41
+ }
42
+
43
+ return (
44
+ <div
45
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
46
+ role="alertdialog"
47
+ aria-modal="true"
48
+ aria-labelledby={title ? titleId : messageId}
49
+ aria-describedby={title ? messageId : undefined}
50
+ >
51
+ <div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
52
+ {title ? (
53
+ <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
54
+ {title}
55
+ </h2>
56
+ ) : null}
57
+ <p
58
+ id={messageId}
59
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
60
+ >
61
+ {message}
62
+ </p>
63
+ <div className="mt-6 flex justify-end">
64
+ <button
65
+ type="button"
66
+ className={tbModalPrimary}
67
+ onClick={onClose}
68
+ >
69
+ {okLabel}
70
+ </button>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ export type DashboardConfirmTypeToConfirm = {
78
+ /** Texte exact que l’utilisateur doit saisir (ex. tout en majuscules). */
79
+ expected: string;
80
+ /** Court paragraphe d’instructions (peut mentionner le mot à taper). */
81
+ instruction: string;
82
+ /** Libellé du champ de saisie. */
83
+ label: string;
84
+ /** `aria-label` du champ. */
85
+ inputAriaLabel: string;
86
+ };
87
+
88
+ /** Confirmation avec Annuler / Confirmer (remplace `confirm()`). */
89
+ export function DashboardConfirmModal({
90
+ open,
91
+ title,
92
+ message,
93
+ cancelLabel,
94
+ confirmLabel,
95
+ confirmVariant = "neutral",
96
+ dismissCheckbox,
97
+ typeToConfirm,
98
+ extra,
99
+ pending = false,
100
+ onCancel,
101
+ onConfirm,
102
+ }: {
103
+ open: boolean;
104
+ title?: string;
105
+ message: string;
106
+ cancelLabel: string;
107
+ confirmLabel: string;
108
+ confirmVariant?: "neutral" | "danger";
109
+ /** Case « ne plus afficher » entre le message et les boutons. */
110
+ dismissCheckbox?: {
111
+ label: string;
112
+ checked: boolean;
113
+ onChange: (next: boolean) => void;
114
+ };
115
+ /** Saisie obligatoire (mot exact) avant d’activer Confirmer. */
116
+ typeToConfirm?: DashboardConfirmTypeToConfirm;
117
+ /** Contenu additionnel (formulaire, etc.) entre le message et la saisie obligatoire. */
118
+ extra?: ReactNode;
119
+ /** Désactive saisie et boutons (ex. action asynchrone en cours). */
120
+ pending?: boolean;
121
+ onCancel: () => void;
122
+ onConfirm: () => void | Promise<void>;
123
+ }) {
124
+ const titleId = useId();
125
+ const messageId = useId();
126
+ const typeFieldId = useId();
127
+ const [typeDraft, setTypeDraft] = useState("");
128
+ useEscapeClose(open, () => {
129
+ if (pending) {
130
+ return;
131
+ }
132
+ onCancel();
133
+ });
134
+
135
+ useEffect(() => {
136
+ if (open) {
137
+ setTypeDraft("");
138
+ }
139
+ }, [open]);
140
+
141
+ if (!open) {
142
+ return null;
143
+ }
144
+
145
+ const typedOk = !typeToConfirm || typeDraft === typeToConfirm.expected;
146
+ const confirmClass = confirmVariant === "danger" ? tbModalDanger : tbModalPrimary;
147
+
148
+ return (
149
+ <div
150
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
151
+ role="dialog"
152
+ aria-modal="true"
153
+ aria-labelledby={title ? titleId : messageId}
154
+ aria-describedby={title ? messageId : undefined}
155
+ >
156
+ <div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
157
+ {title ? (
158
+ <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
159
+ {title}
160
+ </h2>
161
+ ) : null}
162
+ <p
163
+ id={messageId}
164
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
165
+ >
166
+ {message}
167
+ </p>
168
+ {dismissCheckbox ? (
169
+ <label className="mt-4 flex cursor-pointer items-start gap-2.5 text-sm text-zinc-600 dark:text-zinc-400">
170
+ <input
171
+ type="checkbox"
172
+ className="mt-0.5 size-4 shrink-0 rounded border-zinc-300 bg-white text-violet-600 focus:ring-2 focus:ring-violet-500/50 dark:border-zinc-600 dark:bg-zinc-950"
173
+ checked={dismissCheckbox.checked}
174
+ disabled={pending}
175
+ onChange={(e) => dismissCheckbox.onChange(e.target.checked)}
176
+ />
177
+ <span className="leading-snug">{dismissCheckbox.label}</span>
178
+ </label>
179
+ ) : null}
180
+ {extra ? <div className="mt-4">{extra}</div> : null}
181
+ {typeToConfirm ? (
182
+ <div className="mt-4 space-y-2">
183
+ <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{typeToConfirm.instruction}</p>
184
+ <label htmlFor={typeFieldId} className="block text-xs font-semibold uppercase tracking-wide text-zinc-500">
185
+ {typeToConfirm.label}
186
+ </label>
187
+ <input
188
+ id={typeFieldId}
189
+ type="text"
190
+ value={typeDraft}
191
+ onChange={(e) => setTypeDraft(e.target.value)}
192
+ placeholder={typeToConfirm.expected}
193
+ disabled={pending}
194
+ className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
195
+ autoComplete="off"
196
+ spellCheck={false}
197
+ aria-label={typeToConfirm.inputAriaLabel}
198
+ />
199
+ </div>
200
+ ) : null}
201
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
202
+ <button
203
+ type="button"
204
+ className="rounded-lg border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
205
+ disabled={pending}
206
+ onClick={onCancel}
207
+ >
208
+ {cancelLabel}
209
+ </button>
210
+ <button
211
+ type="button"
212
+ className={`${confirmClass} disabled:cursor-not-allowed disabled:opacity-40`}
213
+ disabled={!typedOk || pending}
214
+ onClick={() => {
215
+ if (!typedOk || pending) {
216
+ return;
217
+ }
218
+ void onConfirm();
219
+ }}
220
+ >
221
+ {confirmLabel}
222
+ </button>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ /** Dialogue avec trois actions (ex. annuler / option secondaire / option principale). */
230
+ export function DashboardTriActionModal({
231
+ open,
232
+ title,
233
+ message,
234
+ dismissLabel,
235
+ tertiaryLabel,
236
+ secondaryLabel,
237
+ primaryLabel,
238
+ primaryVariant = "danger",
239
+ dismissCheckbox,
240
+ onDismiss,
241
+ onTertiary,
242
+ onSecondary,
243
+ onPrimary,
244
+ }: {
245
+ open: boolean;
246
+ title?: string;
247
+ message: string;
248
+ dismissLabel: string;
249
+ /** Quatrième action (ex. démarrage en parallèle) — entre Annuler et l’option secondaire. */
250
+ tertiaryLabel?: string;
251
+ secondaryLabel: string;
252
+ primaryLabel: string;
253
+ primaryVariant?: "danger" | "neutral";
254
+ /** Case « ne plus afficher » (mémorisation gérée par le parent). */
255
+ dismissCheckbox?: {
256
+ label: string;
257
+ checked: boolean;
258
+ onChange: (next: boolean) => void;
259
+ };
260
+ onDismiss: () => void;
261
+ onTertiary?: () => void | Promise<void>;
262
+ onSecondary: () => void | Promise<void>;
263
+ onPrimary: () => void | Promise<void>;
264
+ }) {
265
+ const titleId = useId();
266
+ const messageId = useId();
267
+ useEscapeClose(open, onDismiss);
268
+
269
+ if (!open) {
270
+ return null;
271
+ }
272
+
273
+ const primaryClass = primaryVariant === "danger" ? tbModalDanger : tbModalPrimary;
274
+
275
+ return (
276
+ <div
277
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
278
+ role="dialog"
279
+ aria-modal="true"
280
+ aria-labelledby={title ? titleId : messageId}
281
+ aria-describedby={title ? messageId : undefined}
282
+ >
283
+ <div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
284
+ {title ? (
285
+ <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
286
+ {title}
287
+ </h2>
288
+ ) : null}
289
+ <p
290
+ id={messageId}
291
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
292
+ >
293
+ {message}
294
+ </p>
295
+ {dismissCheckbox ? (
296
+ <label className="mt-4 flex cursor-pointer items-start gap-2.5 text-sm text-zinc-600 dark:text-zinc-400">
297
+ <input
298
+ type="checkbox"
299
+ className="mt-0.5 size-4 shrink-0 rounded border-zinc-300 bg-white text-violet-600 focus:ring-2 focus:ring-violet-500/50 dark:border-zinc-600 dark:bg-zinc-950"
300
+ checked={dismissCheckbox.checked}
301
+ onChange={(e) => dismissCheckbox.onChange(e.target.checked)}
302
+ />
303
+ <span className="leading-snug">{dismissCheckbox.label}</span>
304
+ </label>
305
+ ) : null}
306
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
307
+ <button
308
+ type="button"
309
+ className="rounded-lg border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
310
+ onClick={onDismiss}
311
+ >
312
+ {dismissLabel}
313
+ </button>
314
+ {tertiaryLabel && onTertiary ? (
315
+ <button
316
+ type="button"
317
+ className="rounded-lg border border-violet-400/60 bg-violet-500/10 px-4 py-2 text-sm font-medium text-violet-900 hover:bg-violet-500/16 dark:border-violet-400/45 dark:bg-violet-950/45 dark:text-violet-100 dark:hover:bg-violet-950/70"
318
+ onClick={() => void onTertiary()}
319
+ >
320
+ {tertiaryLabel}
321
+ </button>
322
+ ) : null}
323
+ <button
324
+ type="button"
325
+ className={tbModalPrimary}
326
+ onClick={() => void onSecondary()}
327
+ >
328
+ {secondaryLabel}
329
+ </button>
330
+ <button type="button" className={primaryClass} onClick={() => void onPrimary()}>
331
+ {primaryLabel}
332
+ </button>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ );
337
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { DashboardLoadingOverlay } from "@/components/dashboard/DashboardLoadingOverlay";
4
+ import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
5
+ import { readStoredDashboardLang } from "@/lib/dashboardLangStorage";
6
+
7
+ const ELLIPSIS = "\u2026";
8
+
9
+ function inferSplashLang(): Lang {
10
+ if (globalThis.window === undefined) {
11
+ return "en";
12
+ }
13
+ const stored = readStoredDashboardLang();
14
+ if (stored) {
15
+ return stored;
16
+ }
17
+ try {
18
+ const nav = globalThis.navigator?.language?.toLowerCase() ?? "";
19
+ if (nav.startsWith("fr")) {
20
+ return "fr";
21
+ }
22
+ } catch {
23
+ /* ignore */
24
+ }
25
+ return "en";
26
+ }
27
+
28
+ /**
29
+ * Contenu du `fallback` Suspense à la racine : langue alignée sur le stockage ou le navigateur.
30
+ * Côté serveur : ellipse + aria minimal (pas de phrase figée en anglais). Côté client : libellés
31
+ * complets calculés de façon synchrone pour qu’un chargement très bref affiche quand même le texte.
32
+ */
33
+ export function DashboardSuspenseFallback() {
34
+ if (globalThis.window === undefined) {
35
+ return (
36
+ <div className="min-h-screen bg-zinc-100 dark:bg-zinc-900">
37
+ <DashboardLoadingOverlay hydrationTolerant ariaLabel="Kronosys" message={ELLIPSIS} />
38
+ </div>
39
+ );
40
+ }
41
+
42
+ const dt = dashboardStrings(inferSplashLang());
43
+ return (
44
+ <div className="min-h-screen bg-zinc-100 dark:bg-zinc-900">
45
+ <DashboardLoadingOverlay
46
+ hydrationTolerant
47
+ ariaLabel={dt.dashboardLoadingAriaLabel}
48
+ message={dt.dashboardLoadingMessage}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ type ToastItem = { id: number; message: string };
14
+
15
+ const ToastContext = createContext<{ pushToast: (message: string) => void } | null>(null);
16
+
17
+ const TOAST_MS = 5500;
18
+
19
+ export function DashboardToastProvider({ children }: { children: ReactNode }) {
20
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
21
+ const idRef = useRef(0);
22
+
23
+ const pushToast = useCallback((message: string) => {
24
+ const text = message.trim();
25
+ if (!text) {
26
+ return;
27
+ }
28
+ const id = ++idRef.current;
29
+ setToasts((prev) => [...prev, { id, message: text }]);
30
+ window.setTimeout(() => {
31
+ setToasts((prev) => prev.filter((t) => t.id !== id));
32
+ }, TOAST_MS);
33
+ }, []);
34
+
35
+ const value = useMemo(() => ({ pushToast }), [pushToast]);
36
+
37
+ return (
38
+ <ToastContext.Provider value={value}>
39
+ {children}
40
+ <div
41
+ className="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-[min(calc(100vw-2rem),22rem)] flex-col gap-2"
42
+ aria-live="polite"
43
+ >
44
+ {toasts.map((t) => (
45
+ <div
46
+ key={t.id}
47
+ className="pointer-events-auto rounded-lg border border-zinc-200 bg-white/95 px-4 py-3 text-sm leading-snug text-zinc-800 shadow-lg dark:border-zinc-600 dark:bg-zinc-900/95 dark:text-zinc-100"
48
+ >
49
+ {t.message}
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </ToastContext.Provider>
54
+ );
55
+ }
56
+
57
+ /** Retourne `pushToast` ; sans fournisseur, les appels sont ignorés (no-op). */
58
+ export function useDashboardToast(): { pushToast: (message: string) => void } {
59
+ const ctx = useContext(ToastContext);
60
+ if (!ctx) {
61
+ return { pushToast: () => {} };
62
+ }
63
+ return ctx;
64
+ }