@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.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- 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
|
+
}
|