@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,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
5
|
+
import type { SettingsCopy } from "@/lib/settingsCopy";
|
|
6
|
+
import type { GitIdentityPayload } from "@/lib/kronosysApi";
|
|
7
|
+
import { tbModalPrimary } from "@/lib/translucentButtonClasses";
|
|
8
|
+
|
|
9
|
+
function useEscapeClose(open: boolean, onClose: () => void, blocked: boolean) {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!open || blocked) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const onKey = (e: KeyboardEvent) => {
|
|
15
|
+
if (e.key === "Escape") {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
onClose();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
document.addEventListener("keydown", onKey);
|
|
21
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
22
|
+
}, [open, onClose, blocked]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inputClass(disabled: boolean) {
|
|
26
|
+
return `w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 ${
|
|
27
|
+
disabled ? "opacity-50" : ""
|
|
28
|
+
}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function seedFromIdentity(g: GitIdentityPayload | undefined): { name: string; email: string; login: string } {
|
|
32
|
+
return {
|
|
33
|
+
name: typeof g?.gitUserName === "string" ? g.gitUserName : "",
|
|
34
|
+
email: typeof g?.gitUserEmail === "string" ? g.gitUserEmail : "",
|
|
35
|
+
login: typeof g?.gitAccountLogin === "string" ? g.gitAccountLogin : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function GitIdentityQuickSetupModal({
|
|
40
|
+
open,
|
|
41
|
+
onClose,
|
|
42
|
+
dt,
|
|
43
|
+
s,
|
|
44
|
+
initialGitIdentity,
|
|
45
|
+
onSave,
|
|
46
|
+
}: {
|
|
47
|
+
open: boolean;
|
|
48
|
+
onClose: () => void;
|
|
49
|
+
dt: DashboardStrings;
|
|
50
|
+
s: SettingsCopy;
|
|
51
|
+
initialGitIdentity: GitIdentityPayload | undefined;
|
|
52
|
+
onSave: (fields: { gitUserName: string; gitUserEmail: string; gitAccountLogin: string }) => Promise<void>;
|
|
53
|
+
}) {
|
|
54
|
+
const titleId = useId();
|
|
55
|
+
const introId = useId();
|
|
56
|
+
const [name, setName] = useState("");
|
|
57
|
+
const [email, setEmail] = useState("");
|
|
58
|
+
const [login, setLogin] = useState("");
|
|
59
|
+
const [saving, setSaving] = useState(false);
|
|
60
|
+
const [error, setError] = useState<string | null>(null);
|
|
61
|
+
const seededForOpenCycle = useRef(false);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) {
|
|
65
|
+
seededForOpenCycle.current = false;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (seededForOpenCycle.current) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
seededForOpenCycle.current = true;
|
|
72
|
+
const seed = seedFromIdentity(initialGitIdentity);
|
|
73
|
+
setName(seed.name);
|
|
74
|
+
setEmail(seed.email);
|
|
75
|
+
setLogin(seed.login);
|
|
76
|
+
setError(null);
|
|
77
|
+
}, [open, initialGitIdentity]);
|
|
78
|
+
|
|
79
|
+
useEscapeClose(open, onClose, saving);
|
|
80
|
+
|
|
81
|
+
const canSubmit =
|
|
82
|
+
name.trim().length > 0 || email.trim().length > 0 || login.trim().length > 0;
|
|
83
|
+
|
|
84
|
+
const handleSubmit = useCallback(async () => {
|
|
85
|
+
if (!canSubmit || saving) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setSaving(true);
|
|
89
|
+
setError(null);
|
|
90
|
+
try {
|
|
91
|
+
await onSave({
|
|
92
|
+
gitUserName: name.trim(),
|
|
93
|
+
gitUserEmail: email.trim(),
|
|
94
|
+
gitAccountLogin: login.trim(),
|
|
95
|
+
});
|
|
96
|
+
onClose();
|
|
97
|
+
} catch (e: unknown) {
|
|
98
|
+
setError(e instanceof Error ? e.message : dt.gitIdentityModalSaveError);
|
|
99
|
+
} finally {
|
|
100
|
+
setSaving(false);
|
|
101
|
+
}
|
|
102
|
+
}, [canSubmit, saving, onSave, onClose, name, email, login, dt.gitIdentityModalSaveError]);
|
|
103
|
+
|
|
104
|
+
if (!open) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className="fixed inset-0 z-[220] flex items-center justify-center bg-black/60 p-4"
|
|
111
|
+
role="dialog"
|
|
112
|
+
aria-modal="true"
|
|
113
|
+
aria-labelledby={titleId}
|
|
114
|
+
aria-describedby={introId}
|
|
115
|
+
>
|
|
116
|
+
<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">
|
|
117
|
+
<h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
118
|
+
{dt.gitIdentityModalTitle}
|
|
119
|
+
</h2>
|
|
120
|
+
<p
|
|
121
|
+
id={introId}
|
|
122
|
+
className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
|
|
123
|
+
>
|
|
124
|
+
{dt.gitIdentityModalIntro}
|
|
125
|
+
</p>
|
|
126
|
+
|
|
127
|
+
<div className="mt-5 space-y-4">
|
|
128
|
+
<div className="space-y-1.5">
|
|
129
|
+
<label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-name">
|
|
130
|
+
{s.gitIdentityNameLabel}
|
|
131
|
+
</label>
|
|
132
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityNameDesc}</p>
|
|
133
|
+
<input
|
|
134
|
+
id="git-quick-name"
|
|
135
|
+
type="text"
|
|
136
|
+
className={inputClass(saving)}
|
|
137
|
+
value={name}
|
|
138
|
+
onChange={(e) => setName(e.target.value)}
|
|
139
|
+
disabled={saving}
|
|
140
|
+
autoComplete="name"
|
|
141
|
+
spellCheck={false}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="space-y-1.5">
|
|
145
|
+
<label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-email">
|
|
146
|
+
{s.gitIdentityEmailLabel}
|
|
147
|
+
</label>
|
|
148
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityEmailDesc}</p>
|
|
149
|
+
<input
|
|
150
|
+
id="git-quick-email"
|
|
151
|
+
type="email"
|
|
152
|
+
className={inputClass(saving)}
|
|
153
|
+
value={email}
|
|
154
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
155
|
+
disabled={saving}
|
|
156
|
+
autoComplete="email"
|
|
157
|
+
spellCheck={false}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="space-y-1.5">
|
|
161
|
+
<label className="block text-sm font-medium text-zinc-800 dark:text-zinc-200" htmlFor="git-quick-login">
|
|
162
|
+
{s.gitIdentityLoginLabel}
|
|
163
|
+
</label>
|
|
164
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">{s.gitIdentityLoginDesc}</p>
|
|
165
|
+
<input
|
|
166
|
+
id="git-quick-login"
|
|
167
|
+
type="text"
|
|
168
|
+
className={inputClass(saving)}
|
|
169
|
+
value={login}
|
|
170
|
+
onChange={(e) => setLogin(e.target.value)}
|
|
171
|
+
disabled={saving}
|
|
172
|
+
autoComplete="username"
|
|
173
|
+
spellCheck={false}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{canSubmit ? null : (
|
|
179
|
+
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">{dt.gitIdentityModalNeedOneField}</p>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{error ? (
|
|
183
|
+
<p className="mt-3 whitespace-pre-wrap text-sm text-red-600 dark:text-red-400" role="alert">
|
|
184
|
+
{error}
|
|
185
|
+
</p>
|
|
186
|
+
) : null}
|
|
187
|
+
|
|
188
|
+
<p className="mt-4 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">{dt.gitIdentityModalFooter}</p>
|
|
189
|
+
|
|
190
|
+
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
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"
|
|
194
|
+
disabled={saving}
|
|
195
|
+
onClick={onClose}
|
|
196
|
+
>
|
|
197
|
+
{dt.dialogCancelBtn}
|
|
198
|
+
</button>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className={`${tbModalPrimary} disabled:cursor-not-allowed disabled:opacity-40`}
|
|
202
|
+
disabled={!canSubmit || saving}
|
|
203
|
+
onClick={() => void handleSubmit()}
|
|
204
|
+
>
|
|
205
|
+
{saving ? s.gitIdentitySaving : s.gitIdentitySave}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Database, FileJson } from "lucide-react";
|
|
2
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
4
|
+
|
|
5
|
+
function StorageBadge({
|
|
6
|
+
label,
|
|
7
|
+
description,
|
|
8
|
+
icon: Icon,
|
|
9
|
+
}: Readonly<{
|
|
10
|
+
label: string;
|
|
11
|
+
/** Texte d’infobulle (title) et nom accessible (aria-label) */
|
|
12
|
+
description: string;
|
|
13
|
+
icon: LucideIcon;
|
|
14
|
+
}>) {
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
aria-label={description}
|
|
18
|
+
title={description}
|
|
19
|
+
className="inline-flex cursor-default items-center gap-1 rounded-md border border-emerald-500/50 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium leading-tight text-emerald-900 ring-1 ring-emerald-600/15 dark:border-emerald-600/55 dark:bg-emerald-950/45 dark:text-emerald-300 dark:ring-0"
|
|
20
|
+
>
|
|
21
|
+
<Icon className="size-3.5 shrink-0 text-emerald-700 dark:text-emerald-400" aria-hidden />
|
|
22
|
+
{label}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type HeaderLocalPersistenceDriver = "sqlite" | "json";
|
|
28
|
+
|
|
29
|
+
export function HeaderIntegrationBadges({
|
|
30
|
+
t,
|
|
31
|
+
localPersistenceDriver,
|
|
32
|
+
mongoEnabled,
|
|
33
|
+
mongoConnected,
|
|
34
|
+
}: Readonly<{
|
|
35
|
+
t: DashboardStrings;
|
|
36
|
+
localPersistenceDriver: HeaderLocalPersistenceDriver;
|
|
37
|
+
mongoEnabled: boolean;
|
|
38
|
+
mongoConnected: boolean;
|
|
39
|
+
}>) {
|
|
40
|
+
const showMongo = mongoEnabled && mongoConnected;
|
|
41
|
+
let kind: "mongo" | "json" | "sqlite";
|
|
42
|
+
if (showMongo) {
|
|
43
|
+
kind = "mongo";
|
|
44
|
+
} else if (localPersistenceDriver === "json") {
|
|
45
|
+
kind = "json";
|
|
46
|
+
} else {
|
|
47
|
+
kind = "sqlite";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (kind === "mongo") {
|
|
51
|
+
return (
|
|
52
|
+
<span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
|
|
53
|
+
<StorageBadge label={t.storageBadgeLabelMongo} description={t.storageBadgeTooltipMongo} icon={Database} />
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (kind === "json") {
|
|
58
|
+
return (
|
|
59
|
+
<span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
|
|
60
|
+
<StorageBadge label={t.storageBadgeLabelJson} description={t.storageBadgeTooltipJson} icon={FileJson} />
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<span className="inline-flex shrink-0 flex-wrap items-center gap-1.5">
|
|
66
|
+
<StorageBadge label={t.storageBadgeLabelSqlite} description={t.storageBadgeTooltipSqlite} icon={Database} />
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { CircleHelp } from "lucide-react";
|
|
6
|
+
import { useAnchoredFloatingPortalStyle } from "./useAnchoredFloatingPortalStyle";
|
|
7
|
+
|
|
8
|
+
const PANEL_PLACEHOLDER_STYLE: CSSProperties = {
|
|
9
|
+
position: "fixed",
|
|
10
|
+
top: 0,
|
|
11
|
+
left: 0,
|
|
12
|
+
width: 288,
|
|
13
|
+
zIndex: 80,
|
|
14
|
+
visibility: "hidden",
|
|
15
|
+
pointerEvents: "none",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Align = "start" | "end";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Icône (?) avec panneau d’aide : rendu en portail fixe pour éviter le clip `overflow-*`.
|
|
22
|
+
* `align="end"` pour la dernière colonne d’une grille.
|
|
23
|
+
*/
|
|
24
|
+
export function InlineMetricHelpTrigger({
|
|
25
|
+
ariaLabel,
|
|
26
|
+
body,
|
|
27
|
+
align = "start",
|
|
28
|
+
preserveLineBreaks = false,
|
|
29
|
+
panelClassName,
|
|
30
|
+
}: Readonly<{
|
|
31
|
+
ariaLabel: string;
|
|
32
|
+
body: string;
|
|
33
|
+
align?: Align;
|
|
34
|
+
/** Affiche les sauts de ligne du texte (ex. paragraphes séparés par \\n\\n). */
|
|
35
|
+
preserveLineBreaks?: boolean;
|
|
36
|
+
/** Remplace la largeur maximale du panneau (textes d’aide longs). */
|
|
37
|
+
panelClassName?: string;
|
|
38
|
+
}>) {
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const id = useId();
|
|
43
|
+
|
|
44
|
+
const maxWidthRem = panelClassName ? 24 : 18;
|
|
45
|
+
const panelStyle = useAnchoredFloatingPortalStyle(open, triggerRef, panelRef, {
|
|
46
|
+
align,
|
|
47
|
+
maxWidthRem,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const onDoc = (e: MouseEvent) => {
|
|
55
|
+
const n = e.target as Node;
|
|
56
|
+
if (!triggerRef.current?.contains(n) && !panelRef.current?.contains(n)) {
|
|
57
|
+
setOpen(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener("mousedown", onDoc);
|
|
61
|
+
return () => document.removeEventListener("mousedown", onDoc);
|
|
62
|
+
}, [open]);
|
|
63
|
+
|
|
64
|
+
const mergedStyle = panelStyle ?? (open ? PANEL_PLACEHOLDER_STYLE : undefined);
|
|
65
|
+
|
|
66
|
+
const panel =
|
|
67
|
+
open && typeof document !== "undefined" && mergedStyle
|
|
68
|
+
? createPortal(
|
|
69
|
+
<div
|
|
70
|
+
ref={panelRef}
|
|
71
|
+
id={`${id}-metric-help`}
|
|
72
|
+
style={mergedStyle}
|
|
73
|
+
className={`rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-lg normal-case dark:border-zinc-600 dark:bg-zinc-900${panelClassName ? ` ${panelClassName}` : ""}`}
|
|
74
|
+
role="region"
|
|
75
|
+
aria-label={ariaLabel}
|
|
76
|
+
>
|
|
77
|
+
<p
|
|
78
|
+
className={`text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300${preserveLineBreaks ? " whitespace-pre-line" : ""} ${!panelClassName ? "max-w-full" : ""}`}
|
|
79
|
+
>
|
|
80
|
+
{body}
|
|
81
|
+
</p>
|
|
82
|
+
</div>,
|
|
83
|
+
document.body
|
|
84
|
+
)
|
|
85
|
+
: null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="relative inline-flex shrink-0" ref={triggerRef}>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className="rounded p-0.5 text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
aria-expanded={open ? "true" : "false"}
|
|
94
|
+
aria-controls={`${id}-metric-help`}
|
|
95
|
+
onClick={() => setOpen((o) => !o)}
|
|
96
|
+
>
|
|
97
|
+
<CircleHelp size={13} strokeWidth={1.75} aria-hidden />
|
|
98
|
+
</button>
|
|
99
|
+
{panel}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Loader2, X } from "lucide-react";
|
|
5
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
6
|
+
|
|
7
|
+
export type RemoteIssue = { title?: string; number: number | string; source?: string };
|
|
8
|
+
|
|
9
|
+
const SEARCH_DEBOUNCE_MS = 350;
|
|
10
|
+
const SEARCH_MIN_CHARS = 2;
|
|
11
|
+
|
|
12
|
+
/** IID seul : le serveur utilise `iids[]` ; un seul chiffre suffit (ex. « 5 » ou « #12 »). */
|
|
13
|
+
function issueSearchMinChars(trimmed: string): number {
|
|
14
|
+
const compact = trimmed.replace(/\s+/g, "");
|
|
15
|
+
return /^#?\d{1,10}$/.test(compact) ? 1 : SEARCH_MIN_CHARS;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function IssuePickerModal({
|
|
19
|
+
t,
|
|
20
|
+
onClose,
|
|
21
|
+
onSelect,
|
|
22
|
+
fetchIssues,
|
|
23
|
+
}: {
|
|
24
|
+
t: DashboardStrings;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
onSelect: (issue: RemoteIssue) => void;
|
|
27
|
+
fetchIssues: (query: string) => Promise<{ issues: RemoteIssue[]; error?: string }>;
|
|
28
|
+
}) {
|
|
29
|
+
const [search, setSearch] = useState("");
|
|
30
|
+
const [results, setResults] = useState<RemoteIssue[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | undefined>();
|
|
33
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
const requestSeq = useRef(0);
|
|
35
|
+
|
|
36
|
+
const runFetch = useCallback(
|
|
37
|
+
async (q: string) => {
|
|
38
|
+
const seq = ++requestSeq.current;
|
|
39
|
+
setError(undefined);
|
|
40
|
+
try {
|
|
41
|
+
const out = await fetchIssues(q);
|
|
42
|
+
if (seq !== requestSeq.current) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setResults(out.issues);
|
|
46
|
+
setError(out.error);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (seq !== requestSeq.current) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setResults([]);
|
|
52
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
53
|
+
} finally {
|
|
54
|
+
if (seq === requestSeq.current) {
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[fetchIssues],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (debounceRef.current) {
|
|
64
|
+
clearTimeout(debounceRef.current);
|
|
65
|
+
debounceRef.current = null;
|
|
66
|
+
}
|
|
67
|
+
const q = search.trim();
|
|
68
|
+
if (q.length < issueSearchMinChars(q)) {
|
|
69
|
+
requestSeq.current += 1;
|
|
70
|
+
setResults([]);
|
|
71
|
+
setLoading(false);
|
|
72
|
+
setError(undefined);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setLoading(true);
|
|
76
|
+
setError(undefined);
|
|
77
|
+
debounceRef.current = setTimeout(() => {
|
|
78
|
+
debounceRef.current = null;
|
|
79
|
+
void runFetch(q);
|
|
80
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
81
|
+
return () => {
|
|
82
|
+
if (debounceRef.current) {
|
|
83
|
+
clearTimeout(debounceRef.current);
|
|
84
|
+
debounceRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}, [search, runFetch]);
|
|
88
|
+
|
|
89
|
+
const q = search.trim();
|
|
90
|
+
const ready = q.length >= issueSearchMinChars(q);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-labelledby="issue-picker-title"
|
|
98
|
+
onClick={onClose}
|
|
99
|
+
>
|
|
100
|
+
<div
|
|
101
|
+
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
|
102
|
+
onClick={(e) => e.stopPropagation()}
|
|
103
|
+
>
|
|
104
|
+
<div className="flex shrink-0 items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
|
105
|
+
<h3 id="issue-picker-title" className="font-semibold text-zinc-900 dark:text-zinc-100">
|
|
106
|
+
{t.selectIssue}
|
|
107
|
+
</h3>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
|
111
|
+
aria-label={t.issuePickerCloseAria}
|
|
112
|
+
onClick={onClose}
|
|
113
|
+
>
|
|
114
|
+
<X size={20} aria-hidden />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="shrink-0 border-b border-zinc-200 dark:border-zinc-800">
|
|
118
|
+
<input
|
|
119
|
+
className="w-full bg-white px-4 py-2.5 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-2 focus:ring-violet-500/40 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
|
120
|
+
placeholder={t.issuePickerSearchPlaceholder}
|
|
121
|
+
value={search}
|
|
122
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
123
|
+
autoFocus
|
|
124
|
+
aria-label={t.issuePickerSearchPlaceholder}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
128
|
+
{loading ? (
|
|
129
|
+
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-zinc-600 dark:text-zinc-400">
|
|
130
|
+
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
|
131
|
+
<span>{t.issuePickerLoading}</span>
|
|
132
|
+
</div>
|
|
133
|
+
) : error ? (
|
|
134
|
+
<div className="px-4 py-6 text-center text-sm text-red-600 dark:text-red-400">{error}</div>
|
|
135
|
+
) : !ready ? (
|
|
136
|
+
<div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
|
|
137
|
+
{t.issuePickerSearchMinHint}
|
|
138
|
+
</div>
|
|
139
|
+
) : results.length === 0 ? (
|
|
140
|
+
<div className="px-4 py-6 text-center text-sm text-zinc-600 dark:text-zinc-500">
|
|
141
|
+
{t.issuePickerNoResults}
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
145
|
+
{results.map((issue, idx) => (
|
|
146
|
+
<li key={`${String(issue.source)}-${String(issue.number)}-${idx}`}>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="w-full px-4 py-3 text-left text-sm hover:bg-zinc-100/90 dark:hover:bg-zinc-800/60"
|
|
150
|
+
onClick={() => onSelect(issue)}
|
|
151
|
+
>
|
|
152
|
+
<div className="font-medium text-zinc-900 dark:text-zinc-200">{issue.title}</div>
|
|
153
|
+
<div className="mt-1 flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-500">
|
|
154
|
+
{issue.source ? (
|
|
155
|
+
<span className="rounded bg-zinc-200 px-1.5 py-0.5 dark:bg-zinc-800">{issue.source}</span>
|
|
156
|
+
) : null}
|
|
157
|
+
<span>#{issue.number}</span>
|
|
158
|
+
</div>
|
|
159
|
+
</button>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|