@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,435 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
import { markDashboardTourCompleted } from "@/lib/dashboardTourStorage";
|
|
14
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
15
|
+
import { tbVioletTextSm } from "@/lib/translucentButtonClasses";
|
|
16
|
+
|
|
17
|
+
/** Ancres CSS, dans l’ordre des étapes (parcours « spotlight » / coach marks). */
|
|
18
|
+
const BASE_STEP_SELECTORS = [
|
|
19
|
+
"#dashboard-tour-anchor-intro",
|
|
20
|
+
"#dashboard-tour-anchor-column-hints",
|
|
21
|
+
"#dashboard-col-sessions",
|
|
22
|
+
"#dashboard-col-tasks",
|
|
23
|
+
"#dashboard-col-tags",
|
|
24
|
+
"#dashboard-tour-anchor-app-toolbar",
|
|
25
|
+
"#dashboard-tour-anchor-user-storage",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** Même id que le conteneur du minuteur KronoFocus dans l’en-tête (si affiché). */
|
|
29
|
+
const KRONO_FOCUS_HEADER_TOUR_SELECTOR = "#dashboard-tour-anchor-kronoFocus-header";
|
|
30
|
+
|
|
31
|
+
/** Même id que le conteneur de la bannière identité Git sur la page tableau de bord. */
|
|
32
|
+
const GIT_IDENTITY_BANNER_TOUR_SELECTOR = "#dashboard-tour-anchor-git-identity-banner";
|
|
33
|
+
|
|
34
|
+
const HOLE_PADDING_PX = 10;
|
|
35
|
+
const TOOLTIP_MAX_W = 360;
|
|
36
|
+
const VIEW_MARGIN = 12;
|
|
37
|
+
const TOOLTIP_GAP = 12;
|
|
38
|
+
|
|
39
|
+
type HoleRect = { top: number; left: number; width: number; height: number };
|
|
40
|
+
|
|
41
|
+
type TipPlacement = "below" | "above" | "right" | "left";
|
|
42
|
+
|
|
43
|
+
function tooltipIntersectsHole(
|
|
44
|
+
tip: { top: number; left: number; width: number; height: number },
|
|
45
|
+
hole: HoleRect
|
|
46
|
+
): boolean {
|
|
47
|
+
return !(
|
|
48
|
+
tip.left + tip.width <= hole.left ||
|
|
49
|
+
hole.left + hole.width <= tip.left ||
|
|
50
|
+
tip.top + tip.height <= hole.top ||
|
|
51
|
+
hole.top + hole.height <= tip.top
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clampTipPosition(
|
|
56
|
+
top: number,
|
|
57
|
+
left: number,
|
|
58
|
+
ph: number,
|
|
59
|
+
wTip: number,
|
|
60
|
+
vw: number,
|
|
61
|
+
vh: number
|
|
62
|
+
): { top: number; left: number } {
|
|
63
|
+
return {
|
|
64
|
+
top: Math.max(VIEW_MARGIN, Math.min(top, vh - ph - VIEW_MARGIN)),
|
|
65
|
+
left: Math.max(VIEW_MARGIN, Math.min(left, vw - wTip - VIEW_MARGIN)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Calcule une position du panneau qui évite de recouvrir le trou (ex. colonne Sessions étroite). */
|
|
70
|
+
function computeTooltipPosition(
|
|
71
|
+
hole: HoleRect,
|
|
72
|
+
vw: number,
|
|
73
|
+
vh: number,
|
|
74
|
+
wTip: number,
|
|
75
|
+
ph: number,
|
|
76
|
+
placementPriority: readonly TipPlacement[]
|
|
77
|
+
): { top: number; left: number } {
|
|
78
|
+
const g = TOOLTIP_GAP;
|
|
79
|
+
const cx = hole.left + hole.width / 2 - wTip / 2;
|
|
80
|
+
const cy = hole.top + hole.height / 2 - ph / 2;
|
|
81
|
+
|
|
82
|
+
const variants: Record<TipPlacement, { top: number; left: number }> = {
|
|
83
|
+
below: { top: hole.top + hole.height + g, left: cx },
|
|
84
|
+
above: { top: hole.top - g - ph, left: cx },
|
|
85
|
+
right: { top: cy, left: hole.left + hole.width + g },
|
|
86
|
+
left: { top: cy, left: hole.left - g - wTip },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const key of placementPriority) {
|
|
90
|
+
const raw = variants[key];
|
|
91
|
+
const p = clampTipPosition(raw.top, raw.left, ph, wTip, vw, vh);
|
|
92
|
+
if (!tooltipIntersectsHole({ ...p, width: wTip, height: ph }, hole)) {
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fallback = clampTipPosition(variants.below.top, variants.below.left, ph, wTip, vw, vh);
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function useEscapeDismiss(open: boolean, onDismiss: () => void) {
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!open) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const onKey = (e: KeyboardEvent) => {
|
|
107
|
+
if (e.key === "Escape") {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
onDismiss();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
document.addEventListener("keydown", onKey);
|
|
113
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
114
|
+
}, [open, onDismiss]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function expandRect(r: DOMRect, pad: number): HoleRect {
|
|
118
|
+
return {
|
|
119
|
+
top: r.top - pad,
|
|
120
|
+
left: r.left - pad,
|
|
121
|
+
width: r.width + 2 * pad,
|
|
122
|
+
height: r.height + 2 * pad,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function DashboardTour({
|
|
127
|
+
open,
|
|
128
|
+
onOpenChange,
|
|
129
|
+
dt,
|
|
130
|
+
kronoFocusTourStep = false,
|
|
131
|
+
gitIdentityBannerTourStep = false,
|
|
132
|
+
}: {
|
|
133
|
+
open: boolean;
|
|
134
|
+
onOpenChange: (open: boolean) => void;
|
|
135
|
+
dt: DashboardStrings;
|
|
136
|
+
/** Étape KronoFocus : ancre présente seulement si le minuteur est affiché dans l’en-tête. */
|
|
137
|
+
kronoFocusTourStep?: boolean;
|
|
138
|
+
/** Dernière étape : bannière « configurer l’identité Git » (ancre présente seulement si la bannière est affichée). */
|
|
139
|
+
gitIdentityBannerTourStep?: boolean;
|
|
140
|
+
}) {
|
|
141
|
+
const [step, setStep] = useState(0);
|
|
142
|
+
const [hole, setHole] = useState<HoleRect | null>(null);
|
|
143
|
+
const [tipStyle, setTipStyle] = useState<CSSProperties>({});
|
|
144
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
145
|
+
const primaryBtnRef = useRef<HTMLButtonElement>(null);
|
|
146
|
+
|
|
147
|
+
const selectors = useMemo((): string[] => {
|
|
148
|
+
const out: string[] = [...BASE_STEP_SELECTORS];
|
|
149
|
+
if (kronoFocusTourStep) {
|
|
150
|
+
out.push(KRONO_FOCUS_HEADER_TOUR_SELECTOR);
|
|
151
|
+
}
|
|
152
|
+
if (gitIdentityBannerTourStep) {
|
|
153
|
+
out.push(GIT_IDENTITY_BANNER_TOUR_SELECTOR);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}, [kronoFocusTourStep, gitIdentityBannerTourStep]);
|
|
157
|
+
|
|
158
|
+
const steps = useMemo(() => {
|
|
159
|
+
const base: { title: string; body: string }[] = [
|
|
160
|
+
{ title: dt.tourStep1Title, body: dt.tourStep1Body },
|
|
161
|
+
{ title: dt.tourStep2Title, body: dt.tourStep2Body },
|
|
162
|
+
{ title: dt.tourStep3Title, body: dt.tourStep3Body },
|
|
163
|
+
{ title: dt.tourStep4Title, body: dt.tourStep4Body },
|
|
164
|
+
{ title: dt.tourStep5Title, body: dt.tourStep5Body },
|
|
165
|
+
{ title: dt.tourStep6Title, body: dt.tourStep6Body },
|
|
166
|
+
{ title: dt.tourStep7Title, body: dt.tourStep7Body },
|
|
167
|
+
];
|
|
168
|
+
if (kronoFocusTourStep) {
|
|
169
|
+
base.push({ title: dt.tourStep8Title, body: dt.tourStep8Body });
|
|
170
|
+
}
|
|
171
|
+
if (gitIdentityBannerTourStep) {
|
|
172
|
+
base.push({ title: dt.tourStep9Title, body: dt.tourStep9Body });
|
|
173
|
+
}
|
|
174
|
+
return base;
|
|
175
|
+
}, [dt, kronoFocusTourStep, gitIdentityBannerTourStep]);
|
|
176
|
+
|
|
177
|
+
const total = steps.length;
|
|
178
|
+
const last = step >= total - 1;
|
|
179
|
+
const current = steps[step] ?? steps[0];
|
|
180
|
+
const selector = selectors[Math.min(step, selectors.length - 1)];
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (open) {
|
|
184
|
+
setStep(0);
|
|
185
|
+
}
|
|
186
|
+
}, [open]);
|
|
187
|
+
|
|
188
|
+
const finish = useCallback(() => {
|
|
189
|
+
markDashboardTourCompleted();
|
|
190
|
+
onOpenChange(false);
|
|
191
|
+
}, [onOpenChange]);
|
|
192
|
+
|
|
193
|
+
useEscapeDismiss(open, finish);
|
|
194
|
+
|
|
195
|
+
const updateHoleFromDom = useCallback(() => {
|
|
196
|
+
if (!open) {
|
|
197
|
+
setHole(null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const el = document.querySelector(selector);
|
|
201
|
+
if (!el || !(el instanceof HTMLElement)) {
|
|
202
|
+
setHole(null);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
setHole(expandRect(el.getBoundingClientRect(), HOLE_PADDING_PX));
|
|
206
|
+
}, [open, selector]);
|
|
207
|
+
|
|
208
|
+
useLayoutEffect(() => {
|
|
209
|
+
if (!open) {
|
|
210
|
+
setHole(null);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const el = document.querySelector(selector);
|
|
214
|
+
if (el instanceof HTMLElement) {
|
|
215
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
|
|
216
|
+
}
|
|
217
|
+
updateHoleFromDom();
|
|
218
|
+
const raf = requestAnimationFrame(updateHoleFromDom);
|
|
219
|
+
window.addEventListener("scroll", updateHoleFromDom, true);
|
|
220
|
+
window.addEventListener("resize", updateHoleFromDom);
|
|
221
|
+
const observed = el instanceof HTMLElement ? el : null;
|
|
222
|
+
const ro =
|
|
223
|
+
observed && typeof ResizeObserver !== "undefined"
|
|
224
|
+
? new ResizeObserver(() => updateHoleFromDom())
|
|
225
|
+
: null;
|
|
226
|
+
if (observed && ro) {
|
|
227
|
+
ro.observe(observed);
|
|
228
|
+
}
|
|
229
|
+
return () => {
|
|
230
|
+
cancelAnimationFrame(raf);
|
|
231
|
+
window.removeEventListener("scroll", updateHoleFromDom, true);
|
|
232
|
+
window.removeEventListener("resize", updateHoleFromDom);
|
|
233
|
+
ro?.disconnect();
|
|
234
|
+
};
|
|
235
|
+
}, [open, selector, step, updateHoleFromDom]);
|
|
236
|
+
|
|
237
|
+
useLayoutEffect(() => {
|
|
238
|
+
if (!open) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const vw = typeof globalThis.window !== "undefined" ? globalThis.window.innerWidth : 1024;
|
|
242
|
+
const vh = typeof globalThis.window !== "undefined" ? globalThis.window.innerHeight : 768;
|
|
243
|
+
const w = Math.min(TOOLTIP_MAX_W, vw - 2 * VIEW_MARGIN);
|
|
244
|
+
|
|
245
|
+
if (!hole) {
|
|
246
|
+
setTipStyle({
|
|
247
|
+
position: "fixed",
|
|
248
|
+
top: "50%",
|
|
249
|
+
left: "50%",
|
|
250
|
+
transform: "translate(-50%, -50%)",
|
|
251
|
+
width: w,
|
|
252
|
+
maxWidth: "calc(100vw - 2rem)",
|
|
253
|
+
zIndex: 212,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const place = (): void => {
|
|
259
|
+
const panel = panelRef.current;
|
|
260
|
+
const ph = panel?.getBoundingClientRect().height ?? 220;
|
|
261
|
+
/** Étape « Sessions » : colonne étroite à gauche — placer le panneau à droite en priorité pour ne pas masquer le trou. */
|
|
262
|
+
const placementPriority: readonly TipPlacement[] =
|
|
263
|
+
step === 2 ? ["right", "below", "above", "left"] : ["below", "above", "right", "left"];
|
|
264
|
+
const { top, left } = computeTooltipPosition(hole, vw, vh, w, ph, placementPriority);
|
|
265
|
+
setTipStyle({
|
|
266
|
+
position: "fixed",
|
|
267
|
+
top,
|
|
268
|
+
left,
|
|
269
|
+
transform: undefined,
|
|
270
|
+
width: w,
|
|
271
|
+
maxWidth: undefined,
|
|
272
|
+
zIndex: 212,
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
place();
|
|
277
|
+
const raf = globalThis.requestAnimationFrame(place);
|
|
278
|
+
return () => globalThis.cancelAnimationFrame(raf);
|
|
279
|
+
}, [open, hole, step, current.title, current.body]);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (!open) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const t = window.setTimeout(() => primaryBtnRef.current?.focus(), 80);
|
|
286
|
+
return () => window.clearTimeout(t);
|
|
287
|
+
}, [open, step]);
|
|
288
|
+
|
|
289
|
+
if (!open) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const progressLabel = dt.tourProgressLabel.replace("{n}", String(step + 1)).replace("{total}", String(total));
|
|
294
|
+
|
|
295
|
+
const secondaryBtn =
|
|
296
|
+
"rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700";
|
|
297
|
+
|
|
298
|
+
const vw = typeof window !== "undefined" ? window.innerWidth : 0;
|
|
299
|
+
const vh = typeof window !== "undefined" ? window.innerHeight : 0;
|
|
300
|
+
|
|
301
|
+
const fullBackdrop = !hole ? (
|
|
302
|
+
<div className="fixed inset-0 z-[210] bg-black/55" aria-hidden />
|
|
303
|
+
) : null;
|
|
304
|
+
|
|
305
|
+
const dimPanels =
|
|
306
|
+
hole && vw > 0 && vh > 0
|
|
307
|
+
? (() => {
|
|
308
|
+
const { top: t, left: l, width: w, height: h } = hole;
|
|
309
|
+
const topH = Math.max(0, t);
|
|
310
|
+
const bottomTop = t + h;
|
|
311
|
+
const bottomH = Math.max(0, vh - bottomTop);
|
|
312
|
+
const leftW = Math.max(0, l);
|
|
313
|
+
const rightLeft = l + w;
|
|
314
|
+
const rightW = Math.max(0, vw - rightLeft);
|
|
315
|
+
return (
|
|
316
|
+
<>
|
|
317
|
+
<div
|
|
318
|
+
className="fixed bg-black/55"
|
|
319
|
+
style={{ top: 0, left: 0, width: vw, height: topH, zIndex: 210 }}
|
|
320
|
+
aria-hidden
|
|
321
|
+
/>
|
|
322
|
+
<div
|
|
323
|
+
className="fixed bg-black/55"
|
|
324
|
+
style={{ top: bottomTop, left: 0, width: vw, height: bottomH, zIndex: 210 }}
|
|
325
|
+
aria-hidden
|
|
326
|
+
/>
|
|
327
|
+
<div
|
|
328
|
+
className="fixed bg-black/55"
|
|
329
|
+
style={{ top: t, left: 0, width: leftW, height: h, zIndex: 210 }}
|
|
330
|
+
aria-hidden
|
|
331
|
+
/>
|
|
332
|
+
<div
|
|
333
|
+
className="fixed bg-black/55"
|
|
334
|
+
style={{ top: t, left: rightLeft, width: rightW, height: h, zIndex: 210 }}
|
|
335
|
+
aria-hidden
|
|
336
|
+
/>
|
|
337
|
+
<div
|
|
338
|
+
className="pointer-events-none fixed rounded-xl border-2 border-violet-500 shadow-[0_0_0_1px_rgba(139,92,246,0.35)] dark:border-violet-400 dark:shadow-[0_0_0_1px_rgba(167,139,250,0.35)]"
|
|
339
|
+
style={{
|
|
340
|
+
top: t,
|
|
341
|
+
left: l,
|
|
342
|
+
width: w,
|
|
343
|
+
height: h,
|
|
344
|
+
zIndex: 211,
|
|
345
|
+
}}
|
|
346
|
+
aria-hidden
|
|
347
|
+
/>
|
|
348
|
+
</>
|
|
349
|
+
);
|
|
350
|
+
})()
|
|
351
|
+
: null;
|
|
352
|
+
|
|
353
|
+
const node = createPortal(
|
|
354
|
+
<>
|
|
355
|
+
{fullBackdrop}
|
|
356
|
+
{dimPanels}
|
|
357
|
+
<div
|
|
358
|
+
ref={panelRef}
|
|
359
|
+
role="dialog"
|
|
360
|
+
aria-modal="true"
|
|
361
|
+
aria-labelledby="dashboard-tour-title"
|
|
362
|
+
aria-describedby="dashboard-tour-body"
|
|
363
|
+
className="rounded-xl border border-zinc-300 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-900"
|
|
364
|
+
style={tipStyle}
|
|
365
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
366
|
+
>
|
|
367
|
+
<div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
368
|
+
<p className="text-xs font-medium uppercase tracking-wide text-violet-600 dark:text-violet-300">
|
|
369
|
+
{progressLabel}
|
|
370
|
+
</p>
|
|
371
|
+
<h2 id="dashboard-tour-title" className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
|
372
|
+
{current.title}
|
|
373
|
+
</h2>
|
|
374
|
+
</div>
|
|
375
|
+
<div id="dashboard-tour-body" className="max-h-[min(42vh,18rem)] overflow-y-auto px-4 py-3">
|
|
376
|
+
<p className="whitespace-pre-wrap text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{current.body}</p>
|
|
377
|
+
{kronoFocusTourStep && step === BASE_STEP_SELECTORS.length ? (
|
|
378
|
+
<p className="mt-3 text-sm leading-relaxed">
|
|
379
|
+
<a
|
|
380
|
+
href={dt.tourStep8LearnMoreUrl}
|
|
381
|
+
target="_blank"
|
|
382
|
+
rel="noopener noreferrer"
|
|
383
|
+
className="font-medium text-violet-700 underline decoration-violet-400/80 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:decoration-violet-500/60 dark:hover:text-violet-200"
|
|
384
|
+
aria-label={dt.tourStep8LearnMoreAriaLabel}
|
|
385
|
+
>
|
|
386
|
+
{dt.tourStep8LearnMoreLabel}
|
|
387
|
+
</a>
|
|
388
|
+
</p>
|
|
389
|
+
) : null}
|
|
390
|
+
</div>
|
|
391
|
+
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
392
|
+
<div className="flex gap-1.5" role="presentation" aria-hidden>
|
|
393
|
+
{steps.map((_, i) => (
|
|
394
|
+
<span
|
|
395
|
+
key={i}
|
|
396
|
+
className={`h-2 w-2 rounded-full ${i === step ? "bg-violet-500 dark:bg-violet-400" : "bg-zinc-300 dark:bg-zinc-600"}`}
|
|
397
|
+
/>
|
|
398
|
+
))}
|
|
399
|
+
</div>
|
|
400
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
className="text-sm text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
404
|
+
onClick={finish}
|
|
405
|
+
>
|
|
406
|
+
{dt.tourSkipBtn}
|
|
407
|
+
</button>
|
|
408
|
+
{step > 0 ? (
|
|
409
|
+
<button type="button" className={secondaryBtn} onClick={() => setStep((s) => Math.max(0, s - 1))}>
|
|
410
|
+
{dt.tourBackBtn}
|
|
411
|
+
</button>
|
|
412
|
+
) : null}
|
|
413
|
+
{last ? (
|
|
414
|
+
<button ref={primaryBtnRef} type="button" className={tbVioletTextSm} onClick={finish}>
|
|
415
|
+
{dt.tourDoneBtn}
|
|
416
|
+
</button>
|
|
417
|
+
) : (
|
|
418
|
+
<button
|
|
419
|
+
ref={primaryBtnRef}
|
|
420
|
+
type="button"
|
|
421
|
+
className={tbVioletTextSm}
|
|
422
|
+
onClick={() => setStep((s) => Math.min(total - 1, s + 1))}
|
|
423
|
+
>
|
|
424
|
+
{dt.tourNextBtn}
|
|
425
|
+
</button>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</>,
|
|
431
|
+
document.body
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
return node;
|
|
435
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, type ReactNode } from "react";
|
|
4
|
+
import { useDescriptionPopoverAfterMs } from "./useDescriptionPopoverAfterMs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Texte ou pastille : définition en popover après 3 s (survol / focus), si {@link description} est non vide.
|
|
8
|
+
*/
|
|
9
|
+
export function DeferredDescriptionPopoverWrap({
|
|
10
|
+
description,
|
|
11
|
+
chipClassName,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
description?: string | null;
|
|
15
|
+
chipClassName?: string;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}) {
|
|
18
|
+
const { hasDescription, triggerProps, popoverLayer, anchorWrapperProps } =
|
|
19
|
+
useDescriptionPopoverAfterMs(description);
|
|
20
|
+
|
|
21
|
+
const chipMerged = [chipClassName, hasDescription ? "cursor-help select-none" : ""]
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.join(" ");
|
|
24
|
+
|
|
25
|
+
if (!hasDescription) {
|
|
26
|
+
return <span className={chipMerged}>{children}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Fragment>
|
|
31
|
+
<span className="relative inline-flex max-w-full" {...anchorWrapperProps}>
|
|
32
|
+
<span className={chipMerged} {...triggerProps}>
|
|
33
|
+
{children}
|
|
34
|
+
</span>
|
|
35
|
+
</span>
|
|
36
|
+
{popoverLayer}
|
|
37
|
+
</Fragment>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
5
|
+
import { tbModalDanger } from "@/lib/translucentButtonClasses";
|
|
6
|
+
|
|
7
|
+
export function DeleteSessionModal({
|
|
8
|
+
open,
|
|
9
|
+
sessionLabel,
|
|
10
|
+
moveTargets,
|
|
11
|
+
t,
|
|
12
|
+
onClose,
|
|
13
|
+
onConfirm,
|
|
14
|
+
}: {
|
|
15
|
+
open: boolean;
|
|
16
|
+
sessionLabel: string;
|
|
17
|
+
moveTargets: { id: string; label: string }[];
|
|
18
|
+
t: DashboardStrings;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onConfirm: (opts: { moveTasksToSessionId?: string }) => void | Promise<void>;
|
|
21
|
+
}) {
|
|
22
|
+
const [mode, setMode] = useState<"discard" | "move">("discard");
|
|
23
|
+
const [targetId, setTargetId] = useState("");
|
|
24
|
+
|
|
25
|
+
/** Stable key so parent re-renders (new array reference) don’t reset the select. */
|
|
26
|
+
const moveTargetIdsKey = [...moveTargets]
|
|
27
|
+
.map((x) => x.id)
|
|
28
|
+
.sort((a, b) => a.localeCompare(b))
|
|
29
|
+
.join("\0");
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) {
|
|
33
|
+
setMode("discard");
|
|
34
|
+
setTargetId("");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setMode("discard");
|
|
38
|
+
setTargetId(moveTargets[0]?.id ?? "");
|
|
39
|
+
// `moveTargets` omitted on purpose: only `moveTargetIdsKey` (stable across reference churn) should reset the form.
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, [open, moveTargetIdsKey]);
|
|
42
|
+
|
|
43
|
+
if (!open) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const canSubmit = mode === "discard" || (mode === "move" && targetId !== "");
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
|
|
52
|
+
role="dialog"
|
|
53
|
+
aria-modal="true"
|
|
54
|
+
aria-labelledby="del-session-title"
|
|
55
|
+
>
|
|
56
|
+
<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">
|
|
57
|
+
<h2 id="del-session-title" className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
58
|
+
{t.sessionDeleteTitle}
|
|
59
|
+
</h2>
|
|
60
|
+
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
|
61
|
+
<span className="font-medium text-zinc-800 dark:text-zinc-300">{sessionLabel}</span>
|
|
62
|
+
{" — "}
|
|
63
|
+
{t.sessionDeleteIntro}
|
|
64
|
+
</p>
|
|
65
|
+
<div className="mt-4 space-y-3">
|
|
66
|
+
<label className="flex cursor-pointer items-start gap-2 text-sm text-zinc-800 dark:text-zinc-200">
|
|
67
|
+
<input
|
|
68
|
+
type="radio"
|
|
69
|
+
name="delMode"
|
|
70
|
+
checked={mode === "discard"}
|
|
71
|
+
onChange={() => setMode("discard")}
|
|
72
|
+
className="mt-1"
|
|
73
|
+
/>
|
|
74
|
+
<span>{t.sessionDeleteDiscardTasks}</span>
|
|
75
|
+
</label>
|
|
76
|
+
<label className="flex cursor-pointer items-start gap-2 text-sm text-zinc-800 dark:text-zinc-200">
|
|
77
|
+
<input
|
|
78
|
+
type="radio"
|
|
79
|
+
name="delMode"
|
|
80
|
+
checked={mode === "move"}
|
|
81
|
+
onChange={() => setMode("move")}
|
|
82
|
+
className="mt-1"
|
|
83
|
+
/>
|
|
84
|
+
<span>{t.sessionDeleteMoveTasks}</span>
|
|
85
|
+
</label>
|
|
86
|
+
{mode === "move" ? (
|
|
87
|
+
<div className="ml-6">
|
|
88
|
+
<label className="block text-xs font-medium uppercase text-zinc-500 dark:text-zinc-500">
|
|
89
|
+
{t.sessionDeleteTargetLabel}
|
|
90
|
+
</label>
|
|
91
|
+
<select
|
|
92
|
+
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
|
93
|
+
value={targetId}
|
|
94
|
+
onChange={(e) => setTargetId(e.target.value)}
|
|
95
|
+
>
|
|
96
|
+
<option value="">{t.sessionDeleteTargetPlaceholder}</option>
|
|
97
|
+
{moveTargets.map((x) => (
|
|
98
|
+
<option key={x.id} value={x.id}>
|
|
99
|
+
{x.label}
|
|
100
|
+
</option>
|
|
101
|
+
))}
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
) : null}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
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"
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
>
|
|
112
|
+
{t.sessionDeleteCancelBtn}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
disabled={!canSubmit}
|
|
117
|
+
className={tbModalDanger}
|
|
118
|
+
onClick={() =>
|
|
119
|
+
void onConfirm({
|
|
120
|
+
moveTasksToSessionId: mode === "move" ? targetId : undefined,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
{t.sessionDeleteConfirmBtn}
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
4
|
+
|
|
5
|
+
/** Bulle de définition pour le hook `useDescriptionPopoverAfterMs` (contenu portail). */
|
|
6
|
+
export function DescriptionTooltipPortaled({
|
|
7
|
+
domId,
|
|
8
|
+
style,
|
|
9
|
+
text,
|
|
10
|
+
onPointerEnter,
|
|
11
|
+
onPointerLeave,
|
|
12
|
+
}: {
|
|
13
|
+
domId: string;
|
|
14
|
+
style: CSSProperties;
|
|
15
|
+
text: string;
|
|
16
|
+
onPointerEnter: () => void;
|
|
17
|
+
onPointerLeave: () => void;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
id={domId}
|
|
22
|
+
role="tooltip"
|
|
23
|
+
style={style}
|
|
24
|
+
className="pointer-events-auto whitespace-pre-line rounded-lg border border-zinc-600 bg-zinc-900 p-2.5 text-left text-[0.7rem] leading-snug text-zinc-200 shadow-lg dark:bg-zinc-950"
|
|
25
|
+
onPointerEnter={onPointerEnter}
|
|
26
|
+
onPointerLeave={onPointerLeave}
|
|
27
|
+
>
|
|
28
|
+
{text}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|