@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21
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 +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- package/server/sessionWallHydrate.test.ts +0 -46
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
EyeOff,
|
|
6
|
+
FolderKanban,
|
|
7
|
+
Pin,
|
|
8
|
+
PinOff,
|
|
9
|
+
Play,
|
|
10
|
+
Plus,
|
|
11
|
+
Pencil,
|
|
12
|
+
RotateCcw,
|
|
13
|
+
Save,
|
|
14
|
+
Tags,
|
|
15
|
+
Trash2,
|
|
16
|
+
} from "lucide-react";
|
|
5
17
|
import { postKronosysAction } from "@/lib/kronosysApi";
|
|
6
18
|
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
7
19
|
import type { Lang } from "@/lib/dashboardCopy";
|
|
8
20
|
import type { SettingsCopy } from "@/lib/settingsCopy";
|
|
21
|
+
import { SettingsTaskTemplatesSection } from "./SettingsTaskTemplatesSection";
|
|
9
22
|
import {
|
|
10
23
|
buildStartTaskFromDraft,
|
|
11
24
|
formatProjectDisplay,
|
|
@@ -14,7 +27,10 @@ import {
|
|
|
14
27
|
normalizeTagKey,
|
|
15
28
|
parseProjectScopedTag,
|
|
16
29
|
} from "@/lib/taskParsing";
|
|
17
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
DashboardConfirmModal,
|
|
32
|
+
DashboardTriActionModal,
|
|
33
|
+
} from "./DashboardSimpleModal";
|
|
18
34
|
import { useDashboardToast } from "@/components/dashboard/DashboardToastProvider";
|
|
19
35
|
import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
|
|
20
36
|
import {
|
|
@@ -44,7 +60,14 @@ const ICON_BTN_SAVE =
|
|
|
44
60
|
const ICON_BTN_PRIMARY =
|
|
45
61
|
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-violet-500/50 bg-violet-500/10 text-violet-800 shadow-sm transition hover:border-violet-500/75 hover:bg-violet-500/18 disabled:opacity-40 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:border-violet-400/60 dark:hover:bg-violet-600/30";
|
|
46
62
|
|
|
47
|
-
|
|
63
|
+
const RENAME_IMPACT_CONFIRM_STORAGE_KEY =
|
|
64
|
+
"kronosys.settings.renameImpactConfirm.dismiss.v1";
|
|
65
|
+
|
|
66
|
+
type LiveActiveTaskShape = {
|
|
67
|
+
id: string;
|
|
68
|
+
isDone?: boolean;
|
|
69
|
+
manualTaskTimerPaused?: boolean;
|
|
70
|
+
};
|
|
48
71
|
|
|
49
72
|
type CurrentSessionShape = {
|
|
50
73
|
sessionId?: string;
|
|
@@ -52,7 +75,9 @@ type CurrentSessionShape = {
|
|
|
52
75
|
activeTask?: LiveActiveTaskShape | null;
|
|
53
76
|
};
|
|
54
77
|
|
|
55
|
-
function runningTrackingTasks(
|
|
78
|
+
function runningTrackingTasks(
|
|
79
|
+
live: CurrentSessionShape | null | undefined,
|
|
80
|
+
): LiveActiveTaskShape[] {
|
|
56
81
|
if (!live) {
|
|
57
82
|
return [];
|
|
58
83
|
}
|
|
@@ -60,9 +85,12 @@ function runningTrackingTasks(live: CurrentSessionShape | null | undefined): Liv
|
|
|
60
85
|
Array.isArray(live.activeTasks) && live.activeTasks.length > 0
|
|
61
86
|
? live.activeTasks
|
|
62
87
|
: live.activeTask
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return raw.filter(
|
|
88
|
+
? [live.activeTask]
|
|
89
|
+
: [];
|
|
90
|
+
return raw.filter(
|
|
91
|
+
(t): t is LiveActiveTaskShape =>
|
|
92
|
+
!!t && !t.isDone && !t.manualTaskTimerPaused,
|
|
93
|
+
);
|
|
66
94
|
}
|
|
67
95
|
|
|
68
96
|
type ConfirmState =
|
|
@@ -70,7 +98,9 @@ type ConfirmState =
|
|
|
70
98
|
| { kind: "unpin"; tag: string }
|
|
71
99
|
| { kind: "exclude"; tag: string }
|
|
72
100
|
| { kind: "purgeTag"; tag: string; fromExcludedList?: boolean }
|
|
73
|
-
| { kind: "purgeProject"; name: string }
|
|
101
|
+
| { kind: "purgeProject"; name: string }
|
|
102
|
+
| { kind: "renameTag"; sourceTag: string; targetTag: string }
|
|
103
|
+
| { kind: "renameProject"; sourceName: string; targetName: string };
|
|
74
104
|
|
|
75
105
|
type TagEditorRowProps = {
|
|
76
106
|
tag: string;
|
|
@@ -82,6 +112,7 @@ type TagEditorRowProps = {
|
|
|
82
112
|
pinnedKeys: Set<string>;
|
|
83
113
|
post: (body: Record<string, unknown>) => Promise<void>;
|
|
84
114
|
setConfirm: (c: ConfirmState) => void;
|
|
115
|
+
dismissRenameImpactConfirm: boolean;
|
|
85
116
|
onStartTrackedTask?: () => void;
|
|
86
117
|
};
|
|
87
118
|
|
|
@@ -95,15 +126,76 @@ function SettingsTagEditorRow({
|
|
|
95
126
|
pinnedKeys,
|
|
96
127
|
post,
|
|
97
128
|
setConfirm,
|
|
129
|
+
dismissRenameImpactConfirm,
|
|
98
130
|
onStartTrackedTask,
|
|
99
131
|
}: TagEditorRowProps) {
|
|
100
132
|
const tagKey = normalizeTagKey(tag).toLowerCase();
|
|
101
133
|
const isPinned = pinnedKeys.has(tagKey);
|
|
134
|
+
const [renameOpen, setRenameOpen] = useState(false);
|
|
135
|
+
const [renameDraft, setRenameDraft] = useState(normalizeTagKey(tag));
|
|
136
|
+
const canSubmitRename =
|
|
137
|
+
normalizeTagKey(renameDraft) &&
|
|
138
|
+
normalizeTagKey(renameDraft).toLowerCase() !==
|
|
139
|
+
normalizeTagKey(tag).toLowerCase();
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setRenameOpen(false);
|
|
143
|
+
setRenameDraft(normalizeTagKey(tag));
|
|
144
|
+
}, [tag]);
|
|
102
145
|
return (
|
|
103
146
|
<li className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90">
|
|
104
147
|
<div className="flex flex-col gap-2.5">
|
|
105
148
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
106
|
-
|
|
149
|
+
{renameOpen ? (
|
|
150
|
+
<input
|
|
151
|
+
type="text"
|
|
152
|
+
className="min-w-[12rem] flex-1 rounded border border-zinc-300 bg-white px-2 py-1 font-mono 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"
|
|
153
|
+
value={renameDraft}
|
|
154
|
+
onChange={(e) => setRenameDraft(e.target.value)}
|
|
155
|
+
onKeyDown={(e) => {
|
|
156
|
+
if (e.key === "Escape") {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
setRenameDraft(normalizeTagKey(tag));
|
|
159
|
+
setRenameOpen(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (e.key !== "Enter") {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
const target = normalizeTagKey(renameDraft);
|
|
167
|
+
if (
|
|
168
|
+
!target ||
|
|
169
|
+
target.toLowerCase() === normalizeTagKey(tag).toLowerCase()
|
|
170
|
+
) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
setRenameOpen(false);
|
|
174
|
+
if (dismissRenameImpactConfirm) {
|
|
175
|
+
void post({
|
|
176
|
+
type: "renameTagMetadata",
|
|
177
|
+
sourceTag: tag,
|
|
178
|
+
targetTag: target,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
setConfirm({
|
|
183
|
+
kind: "renameTag",
|
|
184
|
+
sourceTag: tag,
|
|
185
|
+
targetTag: target,
|
|
186
|
+
});
|
|
187
|
+
}}
|
|
188
|
+
disabled={disabled}
|
|
189
|
+
aria-label={s.tagRenamePrompt}
|
|
190
|
+
placeholder={s.tagRenamePrompt}
|
|
191
|
+
spellCheck={false}
|
|
192
|
+
autoComplete="off"
|
|
193
|
+
/>
|
|
194
|
+
) : (
|
|
195
|
+
<span className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
|
|
196
|
+
{formatTagDisplay(tag)}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
107
199
|
{isPinned ? (
|
|
108
200
|
<span className="rounded border border-violet-500/45 bg-violet-500/10 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-200">
|
|
109
201
|
{s.tagBadgePinned}
|
|
@@ -115,7 +207,9 @@ function SettingsTagEditorRow({
|
|
|
115
207
|
type="text"
|
|
116
208
|
className={INLINE_INPUT_CLASS}
|
|
117
209
|
value={localTagDesc[tagKey] ?? ""}
|
|
118
|
-
onChange={(e) =>
|
|
210
|
+
onChange={(e) =>
|
|
211
|
+
setLocalTagDesc((prev) => ({ ...prev, [tagKey]: e.target.value }))
|
|
212
|
+
}
|
|
119
213
|
disabled={disabled}
|
|
120
214
|
placeholder={s.tagDescriptionLabel}
|
|
121
215
|
spellCheck
|
|
@@ -166,20 +260,81 @@ function SettingsTagEditorRow({
|
|
|
166
260
|
</button>
|
|
167
261
|
<button
|
|
168
262
|
type="button"
|
|
169
|
-
className={
|
|
263
|
+
className={ICON_BTN}
|
|
170
264
|
disabled={disabled}
|
|
171
|
-
title={s.
|
|
172
|
-
aria-label={s.
|
|
173
|
-
onClick={() =>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
description: localTagDesc[tagKey] ?? "",
|
|
178
|
-
})
|
|
179
|
-
}
|
|
265
|
+
title={s.tagRenameBtn}
|
|
266
|
+
aria-label={s.tagRenameBtn}
|
|
267
|
+
onClick={() => {
|
|
268
|
+
setRenameDraft(normalizeTagKey(tag));
|
|
269
|
+
setRenameOpen(true);
|
|
270
|
+
}}
|
|
180
271
|
>
|
|
181
|
-
<
|
|
272
|
+
<Pencil size={16} strokeWidth={2} aria-hidden />
|
|
182
273
|
</button>
|
|
274
|
+
{renameOpen ? (
|
|
275
|
+
<>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
className={ICON_BTN_SAVE}
|
|
279
|
+
disabled={disabled || !canSubmitRename}
|
|
280
|
+
title={s.descriptionSaveBtn}
|
|
281
|
+
aria-label={s.descriptionSaveBtn}
|
|
282
|
+
onClick={() => {
|
|
283
|
+
const target = normalizeTagKey(renameDraft);
|
|
284
|
+
if (!target) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
setRenameOpen(false);
|
|
288
|
+
if (dismissRenameImpactConfirm) {
|
|
289
|
+
void post({
|
|
290
|
+
type: "renameTagMetadata",
|
|
291
|
+
sourceTag: tag,
|
|
292
|
+
targetTag: target,
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
setConfirm({
|
|
297
|
+
kind: "renameTag",
|
|
298
|
+
sourceTag: tag,
|
|
299
|
+
targetTag: target,
|
|
300
|
+
});
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<Save size={16} strokeWidth={2} aria-hidden />
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
className={ICON_BTN}
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
title={s.dialogCancelBtn}
|
|
310
|
+
aria-label={s.dialogCancelBtn}
|
|
311
|
+
onClick={() => {
|
|
312
|
+
setRenameDraft(normalizeTagKey(tag));
|
|
313
|
+
setRenameOpen(false);
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<RotateCcw size={16} strokeWidth={2} aria-hidden />
|
|
317
|
+
</button>
|
|
318
|
+
</>
|
|
319
|
+
) : null}
|
|
320
|
+
{!renameOpen ? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className={ICON_BTN_SAVE}
|
|
324
|
+
disabled={disabled}
|
|
325
|
+
title={s.descriptionSaveBtn}
|
|
326
|
+
aria-label={s.descriptionSaveBtn}
|
|
327
|
+
onClick={() =>
|
|
328
|
+
void post({
|
|
329
|
+
type: "setTagDescription",
|
|
330
|
+
tag,
|
|
331
|
+
description: localTagDesc[tagKey] ?? "",
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
>
|
|
335
|
+
<Save size={16} strokeWidth={2} aria-hidden />
|
|
336
|
+
</button>
|
|
337
|
+
) : null}
|
|
183
338
|
{onStartTrackedTask ? (
|
|
184
339
|
<button
|
|
185
340
|
type="button"
|
|
@@ -189,7 +344,13 @@ function SettingsTagEditorRow({
|
|
|
189
344
|
aria-label={s.tagStartTrackedTaskBtn}
|
|
190
345
|
onClick={() => void onStartTrackedTask()}
|
|
191
346
|
>
|
|
192
|
-
<Play
|
|
347
|
+
<Play
|
|
348
|
+
size={16}
|
|
349
|
+
strokeWidth={2}
|
|
350
|
+
className="ml-0.5"
|
|
351
|
+
fill="currentColor"
|
|
352
|
+
aria-hidden
|
|
353
|
+
/>
|
|
193
354
|
</button>
|
|
194
355
|
) : null}
|
|
195
356
|
</div>
|
|
@@ -200,7 +361,7 @@ function SettingsTagEditorRow({
|
|
|
200
361
|
|
|
201
362
|
export function SettingsTagsProjectsSection({
|
|
202
363
|
s,
|
|
203
|
-
lang
|
|
364
|
+
lang,
|
|
204
365
|
payload,
|
|
205
366
|
saving,
|
|
206
367
|
refresh,
|
|
@@ -210,36 +371,77 @@ export function SettingsTagsProjectsSection({
|
|
|
210
371
|
lang: Lang;
|
|
211
372
|
payload: KronosysUpdatePayload | null;
|
|
212
373
|
saving: boolean;
|
|
213
|
-
refresh: (options?: {
|
|
374
|
+
refresh: (options?: {
|
|
375
|
+
preserveForm?: boolean;
|
|
376
|
+
routerInvalidate?: boolean;
|
|
377
|
+
}) => Promise<boolean | void>;
|
|
214
378
|
/** `dashboard` : pas de titre de page ni bordure de section (colonne du tableau de bord). */
|
|
215
379
|
variant?: "settings" | "dashboard";
|
|
216
380
|
}) {
|
|
217
|
-
void _lang;
|
|
218
381
|
const pid = variant === "dashboard" ? "dashboard" : "settings";
|
|
219
382
|
const showPageHeader = variant === "settings";
|
|
220
383
|
const [pinGlobalDraft, setPinGlobalDraft] = useState("");
|
|
384
|
+
const [projectDraft, setProjectDraft] = useState("");
|
|
221
385
|
/** Brouillon d’épingle Projet#code par clé de projet (minuscules). */
|
|
222
|
-
const [pinScopedByProject, setPinScopedByProject] = useState<
|
|
386
|
+
const [pinScopedByProject, setPinScopedByProject] = useState<
|
|
387
|
+
Record<string, string>
|
|
388
|
+
>({});
|
|
223
389
|
const [busy, setBusy] = useState(false);
|
|
224
390
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
|
225
391
|
/** Colonne tableau de bord : onglets globaux / projets (paramètres : affichage continu inchangé). */
|
|
226
|
-
const [dashTagsTab, setDashTagsTab] = useState<
|
|
227
|
-
|
|
392
|
+
const [dashTagsTab, setDashTagsTab] = useState<
|
|
393
|
+
"global" | "projects" | "templates"
|
|
394
|
+
>("global");
|
|
395
|
+
const [scopeSourceTag, setScopeSourceTag] = useState("");
|
|
396
|
+
const [scopeTargetProjectLower, setScopeTargetProjectLower] = useState("");
|
|
397
|
+
const pendingStartRef = useRef<{
|
|
398
|
+
name: string;
|
|
399
|
+
tags: string[];
|
|
400
|
+
project?: string;
|
|
401
|
+
} | null>(null);
|
|
228
402
|
const [trackingConflictOpen, setTrackingConflictOpen] = useState(false);
|
|
229
|
-
const [rememberConcurrentChoice, setRememberConcurrentChoice] =
|
|
403
|
+
const [rememberConcurrentChoice, setRememberConcurrentChoice] =
|
|
404
|
+
useState(false);
|
|
405
|
+
const [dismissRenameImpactConfirm, setDismissRenameImpactConfirm] =
|
|
406
|
+
useState(false);
|
|
230
407
|
const { pushToast } = useDashboardToast();
|
|
231
408
|
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
try {
|
|
411
|
+
setDismissRenameImpactConfirm(
|
|
412
|
+
globalThis.localStorage?.getItem(RENAME_IMPACT_CONFIRM_STORAGE_KEY) ===
|
|
413
|
+
"1",
|
|
414
|
+
);
|
|
415
|
+
} catch {
|
|
416
|
+
setDismissRenameImpactConfirm(false);
|
|
417
|
+
}
|
|
418
|
+
}, []);
|
|
419
|
+
|
|
232
420
|
const knownTags = (payload?.knownTags ?? []) as string[];
|
|
233
421
|
const knownProjects = (payload?.knownProjects ?? []) as string[];
|
|
234
422
|
const userKnownTags = (payload?.userKnownTags ?? []) as string[];
|
|
235
423
|
const excludedRaw = (payload?.excludedSuggestionTags ?? []) as string[];
|
|
236
|
-
const tagDescFromPayload = (payload?.tagDescriptions ?? {}) as Record<
|
|
237
|
-
|
|
424
|
+
const tagDescFromPayload = (payload?.tagDescriptions ?? {}) as Record<
|
|
425
|
+
string,
|
|
426
|
+
string
|
|
427
|
+
>;
|
|
428
|
+
const projectDescFromPayload = (payload?.projectDescriptions ?? {}) as Record<
|
|
429
|
+
string,
|
|
430
|
+
string
|
|
431
|
+
>;
|
|
238
432
|
const tagDescSerialized = JSON.stringify(tagDescFromPayload);
|
|
239
433
|
const projectDescSerialized = JSON.stringify(projectDescFromPayload);
|
|
240
434
|
|
|
241
435
|
const [localTagDesc, setLocalTagDesc] = useState<Record<string, string>>({});
|
|
242
|
-
const [localProjectDesc, setLocalProjectDesc] = useState<
|
|
436
|
+
const [localProjectDesc, setLocalProjectDesc] = useState<
|
|
437
|
+
Record<string, string>
|
|
438
|
+
>({});
|
|
439
|
+
const [projectRenameOpenByKey, setProjectRenameOpenByKey] = useState<
|
|
440
|
+
Record<string, boolean>
|
|
441
|
+
>({});
|
|
442
|
+
const [projectRenameDraftByKey, setProjectRenameDraftByKey] = useState<
|
|
443
|
+
Record<string, string>
|
|
444
|
+
>({});
|
|
243
445
|
|
|
244
446
|
useEffect(() => {
|
|
245
447
|
try {
|
|
@@ -251,20 +453,35 @@ export function SettingsTagsProjectsSection({
|
|
|
251
453
|
|
|
252
454
|
useEffect(() => {
|
|
253
455
|
try {
|
|
254
|
-
setLocalProjectDesc(
|
|
456
|
+
setLocalProjectDesc(
|
|
457
|
+
JSON.parse(projectDescSerialized) as Record<string, string>,
|
|
458
|
+
);
|
|
255
459
|
} catch {
|
|
256
460
|
setLocalProjectDesc({});
|
|
257
461
|
}
|
|
258
462
|
}, [projectDescSerialized]);
|
|
259
463
|
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
const nextDraft: Record<string, string> = {};
|
|
466
|
+
for (const proj of knownProjects) {
|
|
467
|
+
const pk = normalizeProjectKey(proj).toLowerCase();
|
|
468
|
+
nextDraft[pk] = normalizeProjectKey(proj);
|
|
469
|
+
}
|
|
470
|
+
setProjectRenameDraftByKey(nextDraft);
|
|
471
|
+
setProjectRenameOpenByKey({});
|
|
472
|
+
}, [knownProjects]);
|
|
473
|
+
|
|
260
474
|
const pinnedKeys = useMemo(
|
|
261
475
|
() => new Set(userKnownTags.map((t) => normalizeTagKey(t).toLowerCase())),
|
|
262
|
-
[userKnownTags]
|
|
476
|
+
[userKnownTags],
|
|
263
477
|
);
|
|
264
478
|
|
|
265
479
|
const { globalKnownTags, scopedSections } = useMemo(() => {
|
|
266
480
|
const global: string[] = [];
|
|
267
|
-
const scopedMap = new Map<
|
|
481
|
+
const scopedMap = new Map<
|
|
482
|
+
string,
|
|
483
|
+
{ tags: string[]; displayProject: string }
|
|
484
|
+
>();
|
|
268
485
|
for (const tag of knownTags) {
|
|
269
486
|
const nk = normalizeTagKey(tag);
|
|
270
487
|
const scoped = parseProjectScopedTag(nk);
|
|
@@ -292,11 +509,15 @@ export function SettingsTagsProjectsSection({
|
|
|
292
509
|
}
|
|
293
510
|
for (const [, entry] of scopedMap) {
|
|
294
511
|
entry.tags.sort((a, b) =>
|
|
295
|
-
normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
|
|
512
|
+
normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
|
|
513
|
+
sensitivity: "base",
|
|
514
|
+
}),
|
|
296
515
|
);
|
|
297
516
|
}
|
|
298
517
|
global.sort((a, b) =>
|
|
299
|
-
normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
|
|
518
|
+
normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
|
|
519
|
+
sensitivity: "base",
|
|
520
|
+
}),
|
|
300
521
|
);
|
|
301
522
|
const projectOrderLower: string[] = [];
|
|
302
523
|
const seen = new Set<string>();
|
|
@@ -322,13 +543,34 @@ export function SettingsTagsProjectsSection({
|
|
|
322
543
|
}, [knownTags, knownProjects]);
|
|
323
544
|
|
|
324
545
|
const knownProjectLowerSet = useMemo(
|
|
325
|
-
() =>
|
|
326
|
-
|
|
546
|
+
() =>
|
|
547
|
+
new Set(knownProjects.map((p) => normalizeProjectKey(p).toLowerCase())),
|
|
548
|
+
[knownProjects],
|
|
549
|
+
);
|
|
550
|
+
const knownProjectOptions = useMemo(
|
|
551
|
+
() =>
|
|
552
|
+
knownProjects.map((p) => ({
|
|
553
|
+
lower: normalizeProjectKey(p).toLowerCase(),
|
|
554
|
+
label: normalizeProjectKey(p),
|
|
555
|
+
})),
|
|
556
|
+
[knownProjects],
|
|
557
|
+
);
|
|
558
|
+
const allKnownTagsSorted = useMemo(
|
|
559
|
+
() =>
|
|
560
|
+
[...knownTags].sort((a, b) =>
|
|
561
|
+
normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
|
|
562
|
+
sensitivity: "base",
|
|
563
|
+
}),
|
|
564
|
+
),
|
|
565
|
+
[knownTags],
|
|
327
566
|
);
|
|
328
567
|
|
|
329
568
|
const orphanScopedSections = useMemo(
|
|
330
|
-
() =>
|
|
331
|
-
|
|
569
|
+
() =>
|
|
570
|
+
scopedSections.filter(
|
|
571
|
+
(sec) => !knownProjectLowerSet.has(sec.projectLower),
|
|
572
|
+
),
|
|
573
|
+
[scopedSections, knownProjectLowerSet],
|
|
332
574
|
);
|
|
333
575
|
|
|
334
576
|
const post = async (body: Record<string, unknown>) => {
|
|
@@ -336,12 +578,44 @@ export function SettingsTagsProjectsSection({
|
|
|
336
578
|
try {
|
|
337
579
|
await postKronosysAction(body);
|
|
338
580
|
await refresh({ preserveForm: true });
|
|
581
|
+
} catch (e: unknown) {
|
|
582
|
+
const fallback =
|
|
583
|
+
lang === "fr"
|
|
584
|
+
? "Impossible d’enregistrer la modification."
|
|
585
|
+
: "Failed to save the change.";
|
|
586
|
+
const detail = e instanceof Error ? e.message.trim() : String(e).trim();
|
|
587
|
+
pushToast(detail ? `${fallback} ${detail}` : fallback);
|
|
339
588
|
} finally {
|
|
340
589
|
setBusy(false);
|
|
341
590
|
}
|
|
342
591
|
};
|
|
343
592
|
|
|
344
593
|
const disabled = saving || busy;
|
|
594
|
+
const sourceScoped = parseProjectScopedTag(normalizeTagKey(scopeSourceTag));
|
|
595
|
+
const scopeTargetTag = useMemo(() => {
|
|
596
|
+
const source = normalizeTagKey(scopeSourceTag);
|
|
597
|
+
if (!source) {
|
|
598
|
+
return "";
|
|
599
|
+
}
|
|
600
|
+
if (!scopeTargetProjectLower) {
|
|
601
|
+
return sourceScoped ? normalizeTagKey(sourceScoped.localTag) : "";
|
|
602
|
+
}
|
|
603
|
+
const targetProject =
|
|
604
|
+
knownProjectOptions.find((p) => p.lower === scopeTargetProjectLower)
|
|
605
|
+
?.label ?? scopeTargetProjectLower;
|
|
606
|
+
const local = sourceScoped ? sourceScoped.localTag : source;
|
|
607
|
+
return normalizeTagKey(`${targetProject}#${local}`);
|
|
608
|
+
}, [
|
|
609
|
+
knownProjectOptions,
|
|
610
|
+
scopeSourceTag,
|
|
611
|
+
scopeTargetProjectLower,
|
|
612
|
+
sourceScoped,
|
|
613
|
+
]);
|
|
614
|
+
const canRunScopeMigration =
|
|
615
|
+
normalizeTagKey(scopeSourceTag) &&
|
|
616
|
+
normalizeTagKey(scopeTargetTag) &&
|
|
617
|
+
normalizeTagKey(scopeSourceTag).toLowerCase() !==
|
|
618
|
+
normalizeTagKey(scopeTargetTag).toLowerCase();
|
|
345
619
|
|
|
346
620
|
const addPinGlobal = async () => {
|
|
347
621
|
const t = pinGlobalDraft.replace(/^#/, "").trim();
|
|
@@ -352,15 +626,38 @@ export function SettingsTagsProjectsSection({
|
|
|
352
626
|
setPinGlobalDraft("");
|
|
353
627
|
};
|
|
354
628
|
|
|
355
|
-
const
|
|
356
|
-
const
|
|
629
|
+
const addProject = async () => {
|
|
630
|
+
const project = normalizeProjectKey(projectDraft);
|
|
631
|
+
if (!project) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (
|
|
635
|
+
knownProjects.some(
|
|
636
|
+
(p) => normalizeProjectKey(p).toLowerCase() === project.toLowerCase(),
|
|
637
|
+
)
|
|
638
|
+
) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await post({ type: "rememberKnownProject", name: project });
|
|
642
|
+
setProjectDraft("");
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const addPinScopedForProject = async (
|
|
646
|
+
canonicalProject: string,
|
|
647
|
+
projectLower: string,
|
|
648
|
+
) => {
|
|
649
|
+
const raw = (pinScopedByProject[projectLower] ?? "")
|
|
650
|
+
.replace(/^#/, "")
|
|
651
|
+
.trim();
|
|
357
652
|
if (!raw) {
|
|
358
653
|
return;
|
|
359
654
|
}
|
|
360
655
|
const parsed = parseProjectScopedTag(raw);
|
|
361
656
|
let tag: string;
|
|
362
657
|
if (parsed) {
|
|
363
|
-
if (
|
|
658
|
+
if (
|
|
659
|
+
normalizeProjectKey(parsed.projectKey).toLowerCase() !== projectLower
|
|
660
|
+
) {
|
|
364
661
|
return;
|
|
365
662
|
}
|
|
366
663
|
tag = `${parsed.projectKey}#${parsed.localTag}`;
|
|
@@ -375,13 +672,17 @@ export function SettingsTagsProjectsSection({
|
|
|
375
672
|
};
|
|
376
673
|
|
|
377
674
|
const scopedPinDraftIsSubmittable = (projectLower: string) => {
|
|
378
|
-
const raw = (pinScopedByProject[projectLower] ?? "")
|
|
675
|
+
const raw = (pinScopedByProject[projectLower] ?? "")
|
|
676
|
+
.replace(/^#/, "")
|
|
677
|
+
.trim();
|
|
379
678
|
if (!raw) {
|
|
380
679
|
return false;
|
|
381
680
|
}
|
|
382
681
|
const parsed = parseProjectScopedTag(raw);
|
|
383
682
|
if (parsed) {
|
|
384
|
-
return
|
|
683
|
+
return (
|
|
684
|
+
normalizeProjectKey(parsed.projectKey).toLowerCase() === projectLower
|
|
685
|
+
);
|
|
385
686
|
}
|
|
386
687
|
return true;
|
|
387
688
|
};
|
|
@@ -424,9 +725,17 @@ export function SettingsTagsProjectsSection({
|
|
|
424
725
|
if (mode !== "parallel") {
|
|
425
726
|
for (const t of running) {
|
|
426
727
|
if (mode === "pause") {
|
|
427
|
-
await postKronosysAction({
|
|
728
|
+
await postKronosysAction({
|
|
729
|
+
type: "setTaskTimerPaused",
|
|
730
|
+
taskId: t.id,
|
|
731
|
+
paused: true,
|
|
732
|
+
});
|
|
428
733
|
} else {
|
|
429
|
-
await postKronosysAction({
|
|
734
|
+
await postKronosysAction({
|
|
735
|
+
type: "finishTask",
|
|
736
|
+
taskId: t.id,
|
|
737
|
+
shouldCommit: false,
|
|
738
|
+
});
|
|
430
739
|
}
|
|
431
740
|
}
|
|
432
741
|
}
|
|
@@ -445,7 +754,7 @@ export function SettingsTagsProjectsSection({
|
|
|
445
754
|
setBusy(false);
|
|
446
755
|
}
|
|
447
756
|
},
|
|
448
|
-
[payload?.current, refresh]
|
|
757
|
+
[payload?.current, refresh],
|
|
449
758
|
);
|
|
450
759
|
|
|
451
760
|
const requestStartTask = useCallback(
|
|
@@ -480,7 +789,14 @@ export function SettingsTagsProjectsSection({
|
|
|
480
789
|
await flushPendingStartTask();
|
|
481
790
|
}
|
|
482
791
|
},
|
|
483
|
-
[
|
|
792
|
+
[
|
|
793
|
+
payload?.current,
|
|
794
|
+
flushPendingStartTask,
|
|
795
|
+
resolveConflictAndStart,
|
|
796
|
+
post,
|
|
797
|
+
pushToast,
|
|
798
|
+
s.tagStartTaskAutoSessionToast,
|
|
799
|
+
],
|
|
484
800
|
);
|
|
485
801
|
|
|
486
802
|
const requestStartFromTag = useCallback(
|
|
@@ -491,7 +807,7 @@ export function SettingsTagsProjectsSection({
|
|
|
491
807
|
const draft = buildStartTaskFromDraft("", [token], projectForPicker);
|
|
492
808
|
void requestStartTask(draft);
|
|
493
809
|
},
|
|
494
|
-
[requestStartTask]
|
|
810
|
+
[requestStartTask],
|
|
495
811
|
);
|
|
496
812
|
|
|
497
813
|
const requestStartFromProject = useCallback(
|
|
@@ -499,7 +815,7 @@ export function SettingsTagsProjectsSection({
|
|
|
499
815
|
const draft = buildStartTaskFromDraft("", [], normalizeProjectKey(proj));
|
|
500
816
|
void requestStartTask(draft);
|
|
501
817
|
},
|
|
502
|
-
[requestStartTask]
|
|
818
|
+
[requestStartTask],
|
|
503
819
|
);
|
|
504
820
|
|
|
505
821
|
const cancelTrackingConflict = useCallback(() => {
|
|
@@ -512,24 +828,34 @@ export function SettingsTagsProjectsSection({
|
|
|
512
828
|
variant === "dashboard"
|
|
513
829
|
? "space-y-5"
|
|
514
830
|
: "scroll-mt-24 space-y-6 border-b border-zinc-200 pb-10 dark:border-zinc-800";
|
|
515
|
-
const rootId =
|
|
831
|
+
const rootId =
|
|
832
|
+
pid === "dashboard" ? "dashboard-tags-projects" : "settings-tags-projects";
|
|
516
833
|
const tagsByProjectScrollId =
|
|
517
|
-
variant === "settings"
|
|
834
|
+
variant === "settings"
|
|
835
|
+
? "settings-tags-by-project"
|
|
836
|
+
: `${pid}-tags-by-project`;
|
|
518
837
|
|
|
519
838
|
const linkedTagsBlock = (
|
|
520
839
|
onStartFromTag: (tag: string) => void,
|
|
521
840
|
sec: { projectLower: string; displayProject: string; tags: string[] },
|
|
522
841
|
anchorSectionId: string | undefined,
|
|
523
842
|
canonicalProject: string,
|
|
524
|
-
opts?: { omitSectionTitle?: boolean }
|
|
843
|
+
opts?: { omitSectionTitle?: boolean },
|
|
525
844
|
) => (
|
|
526
|
-
<div
|
|
845
|
+
<div
|
|
846
|
+
id={anchorSectionId}
|
|
847
|
+
className="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-800"
|
|
848
|
+
>
|
|
527
849
|
{!opts?.omitSectionTitle ? (
|
|
528
850
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
529
851
|
{s.tocSubTagsByProject}
|
|
530
852
|
</h4>
|
|
531
853
|
) : null}
|
|
532
|
-
<div
|
|
854
|
+
<div
|
|
855
|
+
className={`flex max-w-md flex-col gap-2 ${
|
|
856
|
+
opts?.omitSectionTitle ? "" : "mt-3"
|
|
857
|
+
}`}
|
|
858
|
+
>
|
|
533
859
|
<label
|
|
534
860
|
className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
|
|
535
861
|
htmlFor={`${pid}-pin-scoped-${sec.projectLower}`}
|
|
@@ -542,7 +868,10 @@ export function SettingsTagsProjectsSection({
|
|
|
542
868
|
className="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"
|
|
543
869
|
value={pinScopedByProject[sec.projectLower] ?? ""}
|
|
544
870
|
onChange={(e) =>
|
|
545
|
-
setPinScopedByProject((prev) => ({
|
|
871
|
+
setPinScopedByProject((prev) => ({
|
|
872
|
+
...prev,
|
|
873
|
+
[sec.projectLower]: e.target.value,
|
|
874
|
+
}))
|
|
546
875
|
}
|
|
547
876
|
placeholder={s.tagsPinScopedPlaceholder}
|
|
548
877
|
disabled={disabled}
|
|
@@ -559,10 +888,14 @@ export function SettingsTagsProjectsSection({
|
|
|
559
888
|
<button
|
|
560
889
|
type="button"
|
|
561
890
|
className={ICON_BTN_PRIMARY}
|
|
562
|
-
disabled={
|
|
891
|
+
disabled={
|
|
892
|
+
disabled || !scopedPinDraftIsSubmittable(sec.projectLower)
|
|
893
|
+
}
|
|
563
894
|
title={s.tagsPinAddBtn}
|
|
564
895
|
aria-label={s.tagsPinAddBtn}
|
|
565
|
-
onClick={() =>
|
|
896
|
+
onClick={() =>
|
|
897
|
+
void addPinScopedForProject(canonicalProject, sec.projectLower)
|
|
898
|
+
}
|
|
566
899
|
>
|
|
567
900
|
<Plus size={20} strokeWidth={2} aria-hidden />
|
|
568
901
|
</button>
|
|
@@ -573,11 +906,15 @@ export function SettingsTagsProjectsSection({
|
|
|
573
906
|
) : (
|
|
574
907
|
<ul
|
|
575
908
|
className="mt-3"
|
|
576
|
-
aria-label={`${s.tocSubTagsByProject} — ${formatProjectDisplay(
|
|
909
|
+
aria-label={`${s.tocSubTagsByProject} — ${formatProjectDisplay(
|
|
910
|
+
sec.displayProject,
|
|
911
|
+
)}`}
|
|
577
912
|
>
|
|
578
913
|
{sec.tags.map((tag, tagIdx) => (
|
|
579
914
|
<SettingsTagEditorRow
|
|
580
|
-
key={`s-${sec.projectLower}-${normalizeTagKey(
|
|
915
|
+
key={`s-${sec.projectLower}-${normalizeTagKey(
|
|
916
|
+
tag,
|
|
917
|
+
).toLowerCase()}`}
|
|
581
918
|
tag={tag}
|
|
582
919
|
fieldId={`${pid}-scoped-tag-desc-${sec.projectLower}-${tagIdx}`}
|
|
583
920
|
s={s}
|
|
@@ -587,6 +924,7 @@ export function SettingsTagsProjectsSection({
|
|
|
587
924
|
pinnedKeys={pinnedKeys}
|
|
588
925
|
post={post}
|
|
589
926
|
setConfirm={setConfirm}
|
|
927
|
+
dismissRenameImpactConfirm={dismissRenameImpactConfirm}
|
|
590
928
|
onStartTrackedTask={() => void onStartFromTag(tag)}
|
|
591
929
|
/>
|
|
592
930
|
))}
|
|
@@ -600,7 +938,9 @@ export function SettingsTagsProjectsSection({
|
|
|
600
938
|
{showPageHeader ? (
|
|
601
939
|
<>
|
|
602
940
|
<div className="flex flex-wrap items-center gap-2">
|
|
603
|
-
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
|
941
|
+
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
|
942
|
+
{s.sectionTagsProjects}
|
|
943
|
+
</h2>
|
|
604
944
|
<InlineMetricHelpTrigger
|
|
605
945
|
ariaLabel={s.tagsProjectsHelpAria}
|
|
606
946
|
body={s.tagsProjectsHelpBody}
|
|
@@ -608,7 +948,9 @@ export function SettingsTagsProjectsSection({
|
|
|
608
948
|
panelClassName="w-[min(calc(100vw-2rem),26rem)]"
|
|
609
949
|
/>
|
|
610
950
|
</div>
|
|
611
|
-
<p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
951
|
+
<p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
952
|
+
{s.tagsProjectsIntro}
|
|
953
|
+
</p>
|
|
612
954
|
</>
|
|
613
955
|
) : null}
|
|
614
956
|
|
|
@@ -650,7 +992,29 @@ export function SettingsTagsProjectsSection({
|
|
|
650
992
|
}`}
|
|
651
993
|
onClick={() => setDashTagsTab("projects")}
|
|
652
994
|
>
|
|
653
|
-
<FolderKanban
|
|
995
|
+
<FolderKanban
|
|
996
|
+
size={20}
|
|
997
|
+
strokeWidth={2}
|
|
998
|
+
aria-hidden
|
|
999
|
+
className="shrink-0"
|
|
1000
|
+
/>
|
|
1001
|
+
</button>
|
|
1002
|
+
<button
|
|
1003
|
+
type="button"
|
|
1004
|
+
role="tab"
|
|
1005
|
+
id={`${pid}-tab-templates`}
|
|
1006
|
+
aria-selected={dashTagsTab === "templates"}
|
|
1007
|
+
aria-controls={`${pid}-tabpanel-templates`}
|
|
1008
|
+
aria-label={s.tagsProjectsTabTemplates}
|
|
1009
|
+
title={s.tagsProjectsTabTemplates}
|
|
1010
|
+
className={`inline-flex min-w-0 flex-1 items-center justify-center rounded-md px-2 py-2 transition sm:px-2.5 ${
|
|
1011
|
+
dashTagsTab === "templates"
|
|
1012
|
+
? "bg-white text-violet-700 shadow-sm dark:bg-zinc-800 dark:text-violet-300"
|
|
1013
|
+
: "text-zinc-500 hover:bg-zinc-200/80 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100"
|
|
1014
|
+
}`}
|
|
1015
|
+
onClick={() => setDashTagsTab("templates")}
|
|
1016
|
+
>
|
|
1017
|
+
<Save size={20} strokeWidth={2} aria-hidden className="shrink-0" />
|
|
654
1018
|
</button>
|
|
655
1019
|
</div>
|
|
656
1020
|
) : null}
|
|
@@ -658,194 +1022,524 @@ export function SettingsTagsProjectsSection({
|
|
|
658
1022
|
<div
|
|
659
1023
|
role={variant === "dashboard" ? "tabpanel" : undefined}
|
|
660
1024
|
id={variant === "dashboard" ? `${pid}-tabpanel-global` : undefined}
|
|
661
|
-
aria-labelledby={
|
|
1025
|
+
aria-labelledby={
|
|
1026
|
+
variant === "dashboard" ? `${pid}-tab-global` : undefined
|
|
1027
|
+
}
|
|
662
1028
|
hidden={variant === "dashboard" && dashTagsTab !== "global"}
|
|
663
1029
|
className={variant === "dashboard" ? "space-y-5" : "contents"}
|
|
664
1030
|
>
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
<
|
|
670
|
-
{s.
|
|
671
|
-
</
|
|
672
|
-
<
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1031
|
+
<div id={`${pid}-tags-global`} className={CARD_CLASS}>
|
|
1032
|
+
<h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
|
|
1033
|
+
{s.tagsGlobalHeading}
|
|
1034
|
+
</h3>
|
|
1035
|
+
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
|
|
1036
|
+
{s.tagsGlobalIntro}
|
|
1037
|
+
</p>
|
|
1038
|
+
<div className="mt-3 max-w-md rounded-lg border border-zinc-200 bg-white/60 p-3 dark:border-zinc-700 dark:bg-zinc-900/40">
|
|
1039
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
1040
|
+
{s.tagsScopeToolHeading}
|
|
1041
|
+
</h4>
|
|
1042
|
+
{allKnownTagsSorted.length === 0 ? (
|
|
1043
|
+
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
|
1044
|
+
{s.tagsScopeToolNoTags}
|
|
1045
|
+
</p>
|
|
1046
|
+
) : (
|
|
1047
|
+
<div className="mt-2 space-y-2">
|
|
1048
|
+
<label className="block text-xs text-zinc-600 dark:text-zinc-400">
|
|
1049
|
+
<span className="mb-1 block">
|
|
1050
|
+
{s.tagsScopeToolSourceLabel}
|
|
1051
|
+
</span>
|
|
1052
|
+
<select
|
|
1053
|
+
className="w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-xs text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
|
1054
|
+
value={scopeSourceTag}
|
|
1055
|
+
onChange={(e) => setScopeSourceTag(e.target.value)}
|
|
1056
|
+
disabled={disabled}
|
|
1057
|
+
>
|
|
1058
|
+
<option value="" />
|
|
1059
|
+
{allKnownTagsSorted.map((tag) => (
|
|
1060
|
+
<option key={tag} value={tag}>
|
|
1061
|
+
{formatTagDisplay(tag)}
|
|
1062
|
+
</option>
|
|
1063
|
+
))}
|
|
1064
|
+
</select>
|
|
1065
|
+
</label>
|
|
1066
|
+
<label className="block text-xs text-zinc-600 dark:text-zinc-400">
|
|
1067
|
+
<span className="mb-1 block">
|
|
1068
|
+
{s.tagsScopeToolTargetProjectLabel}
|
|
1069
|
+
</span>
|
|
1070
|
+
<select
|
|
1071
|
+
className="w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-xs text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
|
1072
|
+
value={scopeTargetProjectLower}
|
|
1073
|
+
onChange={(e) => setScopeTargetProjectLower(e.target.value)}
|
|
1074
|
+
disabled={disabled}
|
|
1075
|
+
>
|
|
1076
|
+
<option value="">
|
|
1077
|
+
{s.tagsScopeToolTargetGlobalOption}
|
|
1078
|
+
</option>
|
|
1079
|
+
{knownProjectOptions.map((proj) => (
|
|
1080
|
+
<option key={proj.lower} value={proj.lower}>
|
|
1081
|
+
{formatProjectDisplay(proj.label)}
|
|
1082
|
+
</option>
|
|
1083
|
+
))}
|
|
1084
|
+
</select>
|
|
1085
|
+
</label>
|
|
1086
|
+
<div className="flex flex-wrap gap-1.5">
|
|
1087
|
+
<button
|
|
1088
|
+
type="button"
|
|
1089
|
+
className="rounded-md border border-zinc-300 bg-white px-2.5 py-1.5 text-xs text-zinc-700 hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
|
1090
|
+
disabled={disabled || !canRunScopeMigration}
|
|
1091
|
+
onClick={() =>
|
|
1092
|
+
void post({
|
|
1093
|
+
type: "transformKnownTagScope",
|
|
1094
|
+
sourceTag: scopeSourceTag,
|
|
1095
|
+
targetTag: scopeTargetTag,
|
|
1096
|
+
mode: "copy",
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
>
|
|
1100
|
+
{s.tagsScopeToolCopyBtn}
|
|
1101
|
+
</button>
|
|
1102
|
+
<button
|
|
1103
|
+
type="button"
|
|
1104
|
+
className="rounded-md border border-violet-500/50 bg-violet-500/10 px-2.5 py-1.5 text-xs text-violet-800 hover:bg-violet-500/20 disabled:opacity-40 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:bg-violet-600/30"
|
|
1105
|
+
disabled={disabled || !canRunScopeMigration}
|
|
1106
|
+
onClick={() =>
|
|
1107
|
+
void post({
|
|
1108
|
+
type: "transformKnownTagScope",
|
|
1109
|
+
sourceTag: scopeSourceTag,
|
|
1110
|
+
targetTag: scopeTargetTag,
|
|
1111
|
+
mode: "move",
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
>
|
|
1115
|
+
{s.tagsScopeToolMoveBtn}
|
|
1116
|
+
</button>
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
</div>
|
|
1121
|
+
<div className="mt-3 flex max-w-md flex-col gap-2">
|
|
1122
|
+
<label
|
|
1123
|
+
className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
|
|
1124
|
+
htmlFor={`${pid}-pin-global-tag`}
|
|
701
1125
|
>
|
|
702
|
-
|
|
703
|
-
</
|
|
1126
|
+
{s.tagsPinFieldLabel}
|
|
1127
|
+
</label>
|
|
1128
|
+
<input
|
|
1129
|
+
id={`${pid}-pin-global-tag`}
|
|
1130
|
+
type="text"
|
|
1131
|
+
className="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"
|
|
1132
|
+
value={pinGlobalDraft}
|
|
1133
|
+
onChange={(e) => setPinGlobalDraft(e.target.value)}
|
|
1134
|
+
placeholder={s.tagsPinPlaceholder}
|
|
1135
|
+
disabled={disabled}
|
|
1136
|
+
autoComplete="off"
|
|
1137
|
+
spellCheck={false}
|
|
1138
|
+
onKeyDown={(e) => {
|
|
1139
|
+
if (e.key === "Enter") {
|
|
1140
|
+
e.preventDefault();
|
|
1141
|
+
void addPinGlobal();
|
|
1142
|
+
}
|
|
1143
|
+
}}
|
|
1144
|
+
/>
|
|
1145
|
+
<div className="flex">
|
|
1146
|
+
<button
|
|
1147
|
+
type="button"
|
|
1148
|
+
className={ICON_BTN_PRIMARY}
|
|
1149
|
+
disabled={
|
|
1150
|
+
disabled ||
|
|
1151
|
+
!pinGlobalDraft.trim() ||
|
|
1152
|
+
!!parseProjectScopedTag(
|
|
1153
|
+
pinGlobalDraft.replace(/^#/, "").trim(),
|
|
1154
|
+
)
|
|
1155
|
+
}
|
|
1156
|
+
title={s.tagsPinAddBtn}
|
|
1157
|
+
aria-label={s.tagsPinAddBtn}
|
|
1158
|
+
onClick={() => void addPinGlobal()}
|
|
1159
|
+
>
|
|
1160
|
+
<Plus size={20} strokeWidth={2} aria-hidden />
|
|
1161
|
+
</button>
|
|
1162
|
+
</div>
|
|
704
1163
|
</div>
|
|
705
|
-
</div>
|
|
706
1164
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1165
|
+
{globalKnownTags.length === 0 ? (
|
|
1166
|
+
<p className="mt-4 text-sm text-zinc-500">{s.tagsEmptyGlobal}</p>
|
|
1167
|
+
) : (
|
|
1168
|
+
<ul className="mt-4" aria-label={s.tagsGlobalHeading}>
|
|
1169
|
+
{globalKnownTags.map((tag, tagIdx) => (
|
|
1170
|
+
<SettingsTagEditorRow
|
|
1171
|
+
key={`g-${normalizeTagKey(tag).toLowerCase()}`}
|
|
1172
|
+
tag={tag}
|
|
1173
|
+
fieldId={`${pid}-global-tag-desc-${tagIdx}`}
|
|
1174
|
+
s={s}
|
|
1175
|
+
disabled={disabled}
|
|
1176
|
+
localTagDesc={localTagDesc}
|
|
1177
|
+
setLocalTagDesc={setLocalTagDesc}
|
|
1178
|
+
pinnedKeys={pinnedKeys}
|
|
1179
|
+
post={post}
|
|
1180
|
+
setConfirm={setConfirm}
|
|
1181
|
+
dismissRenameImpactConfirm={dismissRenameImpactConfirm}
|
|
1182
|
+
onStartTrackedTask={() => void requestStartFromTag(tag)}
|
|
1183
|
+
/>
|
|
1184
|
+
))}
|
|
1185
|
+
</ul>
|
|
1186
|
+
)}
|
|
1187
|
+
</div>
|
|
729
1188
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1189
|
+
<div id={`${pid}-tags-hidden`} className={CARD_CLASS}>
|
|
1190
|
+
<h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
|
|
1191
|
+
{s.tagsHiddenHeading}
|
|
1192
|
+
</h3>
|
|
1193
|
+
{excludedRaw.length === 0 ? (
|
|
1194
|
+
<p className="mt-3 text-sm text-zinc-500">{s.hiddenTagsEmpty}</p>
|
|
1195
|
+
) : (
|
|
1196
|
+
<ul className="mt-3">
|
|
1197
|
+
{excludedRaw.map((key) => (
|
|
1198
|
+
<li
|
|
1199
|
+
key={key}
|
|
1200
|
+
className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90"
|
|
1201
|
+
>
|
|
1202
|
+
<div className="flex flex-col gap-2">
|
|
1203
|
+
<span className="font-mono text-sm text-zinc-600 line-through decoration-zinc-500 dark:text-zinc-400">
|
|
1204
|
+
{formatTagDisplay(key)}
|
|
1205
|
+
</span>
|
|
1206
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
1207
|
+
<button
|
|
1208
|
+
type="button"
|
|
1209
|
+
className={ICON_BTN}
|
|
1210
|
+
disabled={disabled}
|
|
1211
|
+
title={s.tagRestoreBtn}
|
|
1212
|
+
aria-label={s.tagRestoreBtn}
|
|
1213
|
+
onClick={() =>
|
|
1214
|
+
void post({
|
|
1215
|
+
type: "includeTagFromSuggestions",
|
|
1216
|
+
tag: key,
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
>
|
|
1220
|
+
<RotateCcw size={16} strokeWidth={2} aria-hidden />
|
|
1221
|
+
</button>
|
|
1222
|
+
<button
|
|
1223
|
+
type="button"
|
|
1224
|
+
className={ICON_BTN_DANGER}
|
|
1225
|
+
disabled={disabled}
|
|
1226
|
+
title={s.tagPurgeHiddenAriaLabel}
|
|
1227
|
+
aria-label={s.tagPurgeHiddenAriaLabel}
|
|
1228
|
+
onClick={() =>
|
|
1229
|
+
setConfirm({
|
|
1230
|
+
kind: "purgeTag",
|
|
1231
|
+
tag: key,
|
|
1232
|
+
fromExcludedList: true,
|
|
1233
|
+
})
|
|
1234
|
+
}
|
|
1235
|
+
>
|
|
1236
|
+
<Trash2 size={16} strokeWidth={2} aria-hidden />
|
|
1237
|
+
</button>
|
|
1238
|
+
</div>
|
|
763
1239
|
</div>
|
|
764
|
-
</
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
</div>
|
|
1240
|
+
</li>
|
|
1241
|
+
))}
|
|
1242
|
+
</ul>
|
|
1243
|
+
)}
|
|
1244
|
+
</div>
|
|
770
1245
|
</div>
|
|
771
1246
|
|
|
772
1247
|
<div
|
|
773
1248
|
role={variant === "dashboard" ? "tabpanel" : undefined}
|
|
774
1249
|
id={variant === "dashboard" ? `${pid}-tabpanel-projects` : undefined}
|
|
775
|
-
aria-labelledby={
|
|
1250
|
+
aria-labelledby={
|
|
1251
|
+
variant === "dashboard" ? `${pid}-tab-projects` : undefined
|
|
1252
|
+
}
|
|
776
1253
|
hidden={variant === "dashboard" && dashTagsTab !== "projects"}
|
|
777
1254
|
className={variant === "dashboard" ? "space-y-5" : "contents"}
|
|
778
1255
|
>
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1256
|
+
<div id={`${pid}-tags-saved-projects`} className={CARD_CLASS}>
|
|
1257
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1258
|
+
<h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
|
|
1259
|
+
{s.projectsHeading}
|
|
1260
|
+
</h3>
|
|
1261
|
+
<InlineMetricHelpTrigger
|
|
1262
|
+
ariaLabel={s.projectsHelpAria}
|
|
1263
|
+
body={`${s.projectsIntro}\n\n${s.projectsHelpBody}`}
|
|
1264
|
+
preserveLineBreaks
|
|
1265
|
+
panelClassName="w-[min(calc(100vw-2rem),22rem)]"
|
|
1266
|
+
/>
|
|
1267
|
+
</div>
|
|
1268
|
+
<div className="mt-3 flex max-w-md flex-col gap-2">
|
|
1269
|
+
<label
|
|
1270
|
+
className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
|
|
1271
|
+
htmlFor={`${pid}-add-project`}
|
|
1272
|
+
>
|
|
1273
|
+
{s.projectsAddFieldLabel}
|
|
1274
|
+
</label>
|
|
1275
|
+
<input
|
|
1276
|
+
id={`${pid}-add-project`}
|
|
1277
|
+
type="text"
|
|
1278
|
+
className="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"
|
|
1279
|
+
value={projectDraft}
|
|
1280
|
+
onChange={(e) => setProjectDraft(e.target.value)}
|
|
1281
|
+
placeholder={s.projectsAddPlaceholder}
|
|
1282
|
+
disabled={disabled}
|
|
1283
|
+
autoComplete="off"
|
|
1284
|
+
spellCheck={false}
|
|
1285
|
+
onKeyDown={(e) => {
|
|
1286
|
+
if (e.key === "Enter") {
|
|
1287
|
+
e.preventDefault();
|
|
1288
|
+
void addProject();
|
|
1289
|
+
}
|
|
1290
|
+
}}
|
|
1291
|
+
/>
|
|
1292
|
+
<div className="flex">
|
|
1293
|
+
<button
|
|
1294
|
+
type="button"
|
|
1295
|
+
className={ICON_BTN_PRIMARY}
|
|
1296
|
+
disabled={
|
|
1297
|
+
disabled ||
|
|
1298
|
+
!normalizeProjectKey(projectDraft) ||
|
|
1299
|
+
knownProjects.some(
|
|
1300
|
+
(p) =>
|
|
1301
|
+
normalizeProjectKey(p).toLowerCase() ===
|
|
1302
|
+
normalizeProjectKey(projectDraft).toLowerCase(),
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
title={s.projectsAddBtn}
|
|
1306
|
+
aria-label={s.projectsAddBtn}
|
|
1307
|
+
onClick={() => void addProject()}
|
|
1308
|
+
>
|
|
1309
|
+
<Plus size={20} strokeWidth={2} aria-hidden />
|
|
1310
|
+
</button>
|
|
1311
|
+
</div>
|
|
1312
|
+
</div>
|
|
1313
|
+
{knownProjects.length === 0 && orphanScopedSections.length === 0 ? (
|
|
1314
|
+
<p className="mt-4 text-sm text-zinc-500">{s.projectsEmpty}</p>
|
|
1315
|
+
) : null}
|
|
1316
|
+
{knownProjects.length > 0 ? (
|
|
1317
|
+
<ul className="mt-4">
|
|
1318
|
+
{knownProjects.map((proj, projIdx) => {
|
|
1319
|
+
const pk = normalizeProjectKey(proj).toLowerCase();
|
|
1320
|
+
const projectDescFieldId = `${pid}-project-desc-${projIdx}`;
|
|
1321
|
+
const sec = scopedSections.find(
|
|
1322
|
+
(x) => x.projectLower === pk,
|
|
1323
|
+
) ?? {
|
|
799
1324
|
projectLower: pk,
|
|
800
1325
|
displayProject: proj,
|
|
801
1326
|
tags: [] as string[],
|
|
802
1327
|
};
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
<
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
<
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1328
|
+
const linkedAnchorId =
|
|
1329
|
+
projIdx === 0 ? tagsByProjectScrollId : undefined;
|
|
1330
|
+
return (
|
|
1331
|
+
<li
|
|
1332
|
+
key={proj}
|
|
1333
|
+
className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90"
|
|
1334
|
+
>
|
|
1335
|
+
<div className="flex flex-col gap-2.5">
|
|
1336
|
+
{projectRenameOpenByKey[pk] ? (
|
|
1337
|
+
<input
|
|
1338
|
+
type="text"
|
|
1339
|
+
className="w-full max-w-sm rounded border border-zinc-300 bg-white px-2 py-1 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"
|
|
1340
|
+
value={
|
|
1341
|
+
projectRenameDraftByKey[pk] ??
|
|
1342
|
+
normalizeProjectKey(proj)
|
|
1343
|
+
}
|
|
1344
|
+
onChange={(e) =>
|
|
1345
|
+
setProjectRenameDraftByKey((prev) => ({
|
|
1346
|
+
...prev,
|
|
1347
|
+
[pk]: e.target.value,
|
|
1348
|
+
}))
|
|
1349
|
+
}
|
|
1350
|
+
onKeyDown={(e) => {
|
|
1351
|
+
if (e.key === "Escape") {
|
|
1352
|
+
e.preventDefault();
|
|
1353
|
+
setProjectRenameDraftByKey((prev) => ({
|
|
1354
|
+
...prev,
|
|
1355
|
+
[pk]: normalizeProjectKey(proj),
|
|
1356
|
+
}));
|
|
1357
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1358
|
+
...prev,
|
|
1359
|
+
[pk]: false,
|
|
1360
|
+
}));
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (e.key !== "Enter") {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
e.preventDefault();
|
|
1367
|
+
const target = normalizeProjectKey(
|
|
1368
|
+
projectRenameDraftByKey[pk] ?? "",
|
|
1369
|
+
);
|
|
1370
|
+
if (
|
|
1371
|
+
!target ||
|
|
1372
|
+
target.toLowerCase() ===
|
|
1373
|
+
normalizeProjectKey(proj).toLowerCase()
|
|
1374
|
+
) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
if (dismissRenameImpactConfirm) {
|
|
1378
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1379
|
+
...prev,
|
|
1380
|
+
[pk]: false,
|
|
1381
|
+
}));
|
|
1382
|
+
void post({
|
|
1383
|
+
type: "renameProjectMetadata",
|
|
1384
|
+
sourceName: proj,
|
|
1385
|
+
targetName: target,
|
|
1386
|
+
});
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1390
|
+
...prev,
|
|
1391
|
+
[pk]: false,
|
|
1392
|
+
}));
|
|
1393
|
+
setConfirm({
|
|
1394
|
+
kind: "renameProject",
|
|
1395
|
+
sourceName: proj,
|
|
1396
|
+
targetName: target,
|
|
1397
|
+
});
|
|
1398
|
+
}}
|
|
1399
|
+
disabled={disabled}
|
|
1400
|
+
spellCheck={false}
|
|
1401
|
+
autoComplete="off"
|
|
1402
|
+
/>
|
|
1403
|
+
) : (
|
|
1404
|
+
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
1405
|
+
{formatProjectDisplay(proj)}
|
|
1406
|
+
</span>
|
|
1407
|
+
)}
|
|
1408
|
+
<input
|
|
1409
|
+
id={projectDescFieldId}
|
|
1410
|
+
type="text"
|
|
1411
|
+
className={INLINE_INPUT_CLASS}
|
|
1412
|
+
value={localProjectDesc[pk] ?? ""}
|
|
1413
|
+
onChange={(e) =>
|
|
1414
|
+
setLocalProjectDesc((prev) => ({
|
|
1415
|
+
...prev,
|
|
1416
|
+
[pk]: e.target.value,
|
|
1417
|
+
}))
|
|
1418
|
+
}
|
|
1419
|
+
disabled={disabled}
|
|
1420
|
+
placeholder={s.projectDescriptionLabel}
|
|
1421
|
+
spellCheck
|
|
1422
|
+
/>
|
|
1423
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
1424
|
+
{!projectRenameOpenByKey[pk] ? (
|
|
1425
|
+
<button
|
|
1426
|
+
type="button"
|
|
1427
|
+
className={ICON_BTN_SAVE}
|
|
1428
|
+
disabled={disabled}
|
|
1429
|
+
title={s.descriptionSaveBtn}
|
|
1430
|
+
aria-label={s.descriptionSaveBtn}
|
|
1431
|
+
onClick={() =>
|
|
1432
|
+
void post({
|
|
1433
|
+
type: "setProjectDescription",
|
|
1434
|
+
name: proj,
|
|
1435
|
+
description: localProjectDesc[pk] ?? "",
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
>
|
|
1439
|
+
<Save size={16} strokeWidth={2} aria-hidden />
|
|
1440
|
+
</button>
|
|
1441
|
+
) : null}
|
|
823
1442
|
<button
|
|
824
1443
|
type="button"
|
|
825
|
-
className={
|
|
1444
|
+
className={ICON_BTN_DANGER}
|
|
826
1445
|
disabled={disabled}
|
|
827
|
-
title={s.
|
|
828
|
-
aria-label={s.
|
|
1446
|
+
title={s.projectsRemoveBtn}
|
|
1447
|
+
aria-label={s.projectsRemoveBtn}
|
|
829
1448
|
onClick={() =>
|
|
830
|
-
|
|
831
|
-
type: "setProjectDescription",
|
|
832
|
-
name: proj,
|
|
833
|
-
description: localProjectDesc[pk] ?? "",
|
|
834
|
-
})
|
|
1449
|
+
setConfirm({ kind: "purgeProject", name: proj })
|
|
835
1450
|
}
|
|
836
1451
|
>
|
|
837
|
-
<
|
|
1452
|
+
<Trash2 size={16} strokeWidth={2} aria-hidden />
|
|
838
1453
|
</button>
|
|
839
1454
|
<button
|
|
840
1455
|
type="button"
|
|
841
|
-
className={
|
|
1456
|
+
className={ICON_BTN}
|
|
842
1457
|
disabled={disabled}
|
|
843
|
-
title={s.
|
|
844
|
-
aria-label={s.
|
|
845
|
-
onClick={() =>
|
|
1458
|
+
title={s.projectsRenameBtn}
|
|
1459
|
+
aria-label={s.projectsRenameBtn}
|
|
1460
|
+
onClick={() => {
|
|
1461
|
+
setProjectRenameDraftByKey((prev) => ({
|
|
1462
|
+
...prev,
|
|
1463
|
+
[pk]: normalizeProjectKey(proj),
|
|
1464
|
+
}));
|
|
1465
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1466
|
+
...prev,
|
|
1467
|
+
[pk]: true,
|
|
1468
|
+
}));
|
|
1469
|
+
}}
|
|
846
1470
|
>
|
|
847
|
-
<
|
|
1471
|
+
<Pencil size={16} strokeWidth={2} aria-hidden />
|
|
848
1472
|
</button>
|
|
1473
|
+
{projectRenameOpenByKey[pk] ? (
|
|
1474
|
+
<>
|
|
1475
|
+
<button
|
|
1476
|
+
type="button"
|
|
1477
|
+
className={ICON_BTN_SAVE}
|
|
1478
|
+
disabled={
|
|
1479
|
+
disabled ||
|
|
1480
|
+
!normalizeProjectKey(
|
|
1481
|
+
projectRenameDraftByKey[pk] ?? "",
|
|
1482
|
+
) ||
|
|
1483
|
+
normalizeProjectKey(
|
|
1484
|
+
projectRenameDraftByKey[pk] ?? "",
|
|
1485
|
+
).toLowerCase() ===
|
|
1486
|
+
normalizeProjectKey(proj).toLowerCase()
|
|
1487
|
+
}
|
|
1488
|
+
title={s.descriptionSaveBtn}
|
|
1489
|
+
aria-label={s.descriptionSaveBtn}
|
|
1490
|
+
onClick={() => {
|
|
1491
|
+
const target = normalizeProjectKey(
|
|
1492
|
+
projectRenameDraftByKey[pk] ?? "",
|
|
1493
|
+
);
|
|
1494
|
+
if (!target) {
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1498
|
+
...prev,
|
|
1499
|
+
[pk]: false,
|
|
1500
|
+
}));
|
|
1501
|
+
if (dismissRenameImpactConfirm) {
|
|
1502
|
+
void post({
|
|
1503
|
+
type: "renameProjectMetadata",
|
|
1504
|
+
sourceName: proj,
|
|
1505
|
+
targetName: target,
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
setConfirm({
|
|
1510
|
+
kind: "renameProject",
|
|
1511
|
+
sourceName: proj,
|
|
1512
|
+
targetName: target,
|
|
1513
|
+
});
|
|
1514
|
+
}}
|
|
1515
|
+
>
|
|
1516
|
+
<Save size={16} strokeWidth={2} aria-hidden />
|
|
1517
|
+
</button>
|
|
1518
|
+
<button
|
|
1519
|
+
type="button"
|
|
1520
|
+
className={ICON_BTN}
|
|
1521
|
+
disabled={disabled}
|
|
1522
|
+
title={s.dialogCancelBtn}
|
|
1523
|
+
aria-label={s.dialogCancelBtn}
|
|
1524
|
+
onClick={() => {
|
|
1525
|
+
setProjectRenameDraftByKey((prev) => ({
|
|
1526
|
+
...prev,
|
|
1527
|
+
[pk]: normalizeProjectKey(proj),
|
|
1528
|
+
}));
|
|
1529
|
+
setProjectRenameOpenByKey((prev) => ({
|
|
1530
|
+
...prev,
|
|
1531
|
+
[pk]: false,
|
|
1532
|
+
}));
|
|
1533
|
+
}}
|
|
1534
|
+
>
|
|
1535
|
+
<RotateCcw
|
|
1536
|
+
size={16}
|
|
1537
|
+
strokeWidth={2}
|
|
1538
|
+
aria-hidden
|
|
1539
|
+
/>
|
|
1540
|
+
</button>
|
|
1541
|
+
</>
|
|
1542
|
+
) : null}
|
|
849
1543
|
<button
|
|
850
1544
|
type="button"
|
|
851
1545
|
className={ICON_BTN_PRIMARY}
|
|
@@ -854,50 +1548,89 @@ export function SettingsTagsProjectsSection({
|
|
|
854
1548
|
aria-label={s.tagStartTrackedTaskFromProjectBtn}
|
|
855
1549
|
onClick={() => void requestStartFromProject(proj)}
|
|
856
1550
|
>
|
|
857
|
-
<Play
|
|
1551
|
+
<Play
|
|
1552
|
+
size={16}
|
|
1553
|
+
strokeWidth={2}
|
|
1554
|
+
className="ml-0.5"
|
|
1555
|
+
fill="currentColor"
|
|
1556
|
+
aria-hidden
|
|
1557
|
+
/>
|
|
858
1558
|
</button>
|
|
1559
|
+
</div>
|
|
1560
|
+
{linkedTagsBlock(
|
|
1561
|
+
requestStartFromTag,
|
|
1562
|
+
sec,
|
|
1563
|
+
linkedAnchorId,
|
|
1564
|
+
proj,
|
|
1565
|
+
)}
|
|
859
1566
|
</div>
|
|
860
|
-
|
|
1567
|
+
</li>
|
|
1568
|
+
);
|
|
1569
|
+
})}
|
|
1570
|
+
</ul>
|
|
1571
|
+
) : null}
|
|
1572
|
+
{orphanScopedSections.length > 0 ? (
|
|
1573
|
+
<div
|
|
1574
|
+
className={`${
|
|
1575
|
+
knownProjects.length > 0
|
|
1576
|
+
? "mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800"
|
|
1577
|
+
: "mt-4"
|
|
1578
|
+
}`}
|
|
1579
|
+
>
|
|
1580
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
1581
|
+
<h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
|
|
1582
|
+
{s.tagsByProjectHeading}
|
|
1583
|
+
</h3>
|
|
1584
|
+
<InlineMetricHelpTrigger
|
|
1585
|
+
ariaLabel={s.tagsByProjectOrphanHelpAria}
|
|
1586
|
+
body={s.tagsByProjectOrphanIntro}
|
|
1587
|
+
panelClassName="w-[min(calc(100vw-2rem),26rem)]"
|
|
1588
|
+
/>
|
|
1589
|
+
</div>
|
|
1590
|
+
<div className="mt-4 space-y-6">
|
|
1591
|
+
{orphanScopedSections.map((sec, orIdx) => (
|
|
1592
|
+
<div
|
|
1593
|
+
key={sec.projectLower}
|
|
1594
|
+
className="rounded-lg border border-dashed border-zinc-300/90 bg-white/50 p-3 dark:border-zinc-600/80 dark:bg-zinc-950/35"
|
|
1595
|
+
>
|
|
1596
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
1597
|
+
{formatProjectDisplay(sec.displayProject)}
|
|
1598
|
+
</h4>
|
|
1599
|
+
{linkedTagsBlock(
|
|
1600
|
+
requestStartFromTag,
|
|
1601
|
+
sec,
|
|
1602
|
+
knownProjects.length === 0 && orIdx === 0
|
|
1603
|
+
? tagsByProjectScrollId
|
|
1604
|
+
: undefined,
|
|
1605
|
+
sec.displayProject,
|
|
1606
|
+
{ omitSectionTitle: true },
|
|
1607
|
+
)}
|
|
861
1608
|
</div>
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
})}
|
|
865
|
-
</ul>
|
|
866
|
-
) : null}
|
|
867
|
-
{orphanScopedSections.length > 0 ? (
|
|
868
|
-
<div
|
|
869
|
-
className={`${knownProjects.length > 0 ? "mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800" : "mt-4"}`}
|
|
870
|
-
>
|
|
871
|
-
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
872
|
-
<h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.tagsByProjectHeading}</h3>
|
|
873
|
-
<InlineMetricHelpTrigger
|
|
874
|
-
ariaLabel={s.tagsByProjectOrphanHelpAria}
|
|
875
|
-
body={s.tagsByProjectOrphanIntro}
|
|
876
|
-
panelClassName="w-[min(calc(100vw-2rem),26rem)]"
|
|
877
|
-
/>
|
|
878
|
-
</div>
|
|
879
|
-
<div className="mt-4 space-y-6">
|
|
880
|
-
{orphanScopedSections.map((sec, orIdx) => (
|
|
881
|
-
<div
|
|
882
|
-
key={sec.projectLower}
|
|
883
|
-
className="rounded-lg border border-dashed border-zinc-300/90 bg-white/50 p-3 dark:border-zinc-600/80 dark:bg-zinc-950/35"
|
|
884
|
-
>
|
|
885
|
-
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
886
|
-
{formatProjectDisplay(sec.displayProject)}
|
|
887
|
-
</h4>
|
|
888
|
-
{linkedTagsBlock(
|
|
889
|
-
requestStartFromTag,
|
|
890
|
-
sec,
|
|
891
|
-
knownProjects.length === 0 && orIdx === 0 ? tagsByProjectScrollId : undefined,
|
|
892
|
-
sec.displayProject,
|
|
893
|
-
{ omitSectionTitle: true }
|
|
894
|
-
)}
|
|
895
|
-
</div>
|
|
896
|
-
))}
|
|
1609
|
+
))}
|
|
1610
|
+
</div>
|
|
897
1611
|
</div>
|
|
898
|
-
|
|
899
|
-
|
|
1612
|
+
) : null}
|
|
1613
|
+
</div>
|
|
900
1614
|
</div>
|
|
1615
|
+
|
|
1616
|
+
<div
|
|
1617
|
+
role={variant === "dashboard" ? "tabpanel" : undefined}
|
|
1618
|
+
id={variant === "dashboard" ? `${pid}-tabpanel-templates` : undefined}
|
|
1619
|
+
aria-labelledby={
|
|
1620
|
+
variant === "dashboard" ? `${pid}-tab-templates` : undefined
|
|
1621
|
+
}
|
|
1622
|
+
hidden={variant === "dashboard" && dashTagsTab !== "templates"}
|
|
1623
|
+
className={variant === "dashboard" ? "space-y-5" : "contents"}
|
|
1624
|
+
>
|
|
1625
|
+
{payload ? (
|
|
1626
|
+
<SettingsTaskTemplatesSection
|
|
1627
|
+
s={s}
|
|
1628
|
+
lang={lang}
|
|
1629
|
+
payload={payload}
|
|
1630
|
+
saving={saving || busy}
|
|
1631
|
+
refresh={refresh}
|
|
1632
|
+
/>
|
|
1633
|
+
) : null}
|
|
901
1634
|
</div>
|
|
902
1635
|
|
|
903
1636
|
<DashboardConfirmModal
|
|
@@ -932,7 +1665,9 @@ export function SettingsTagsProjectsSection({
|
|
|
932
1665
|
<DashboardConfirmModal
|
|
933
1666
|
open={confirm?.kind === "purgeTag"}
|
|
934
1667
|
message={
|
|
935
|
-
confirm?.kind === "purgeTag" && confirm.fromExcludedList
|
|
1668
|
+
confirm?.kind === "purgeTag" && confirm.fromExcludedList
|
|
1669
|
+
? s.tagPurgeHiddenConfirm
|
|
1670
|
+
: s.tagPurgeConfirm
|
|
936
1671
|
}
|
|
937
1672
|
cancelLabel={s.dialogCancelBtn}
|
|
938
1673
|
confirmLabel={s.dialogConfirmBtn}
|
|
@@ -954,6 +1689,74 @@ export function SettingsTagsProjectsSection({
|
|
|
954
1689
|
}
|
|
955
1690
|
}}
|
|
956
1691
|
/>
|
|
1692
|
+
<DashboardConfirmModal
|
|
1693
|
+
open={confirm?.kind === "renameTag"}
|
|
1694
|
+
message={s.tagRenameImpactConfirm}
|
|
1695
|
+
cancelLabel={s.dialogCancelBtn}
|
|
1696
|
+
confirmLabel={s.dialogConfirmBtn}
|
|
1697
|
+
confirmVariant="danger"
|
|
1698
|
+
dismissCheckbox={{
|
|
1699
|
+
label: s.renameImpactDontShowAgain,
|
|
1700
|
+
checked: dismissRenameImpactConfirm,
|
|
1701
|
+
onChange: setDismissRenameImpactConfirm,
|
|
1702
|
+
}}
|
|
1703
|
+
onCancel={() => setConfirm(null)}
|
|
1704
|
+
onConfirm={() => {
|
|
1705
|
+
if (confirm?.kind !== "renameTag") {
|
|
1706
|
+
setConfirm(null);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const { sourceTag, targetTag } = confirm;
|
|
1710
|
+
if (dismissRenameImpactConfirm) {
|
|
1711
|
+
try {
|
|
1712
|
+
globalThis.localStorage?.setItem(
|
|
1713
|
+
RENAME_IMPACT_CONFIRM_STORAGE_KEY,
|
|
1714
|
+
"1",
|
|
1715
|
+
);
|
|
1716
|
+
} catch {}
|
|
1717
|
+
}
|
|
1718
|
+
setConfirm(null);
|
|
1719
|
+
void post({
|
|
1720
|
+
type: "renameTagMetadata",
|
|
1721
|
+
sourceTag,
|
|
1722
|
+
targetTag,
|
|
1723
|
+
});
|
|
1724
|
+
}}
|
|
1725
|
+
/>
|
|
1726
|
+
<DashboardConfirmModal
|
|
1727
|
+
open={confirm?.kind === "renameProject"}
|
|
1728
|
+
message={s.projectRenameImpactConfirm}
|
|
1729
|
+
cancelLabel={s.dialogCancelBtn}
|
|
1730
|
+
confirmLabel={s.dialogConfirmBtn}
|
|
1731
|
+
confirmVariant="danger"
|
|
1732
|
+
dismissCheckbox={{
|
|
1733
|
+
label: s.renameImpactDontShowAgain,
|
|
1734
|
+
checked: dismissRenameImpactConfirm,
|
|
1735
|
+
onChange: setDismissRenameImpactConfirm,
|
|
1736
|
+
}}
|
|
1737
|
+
onCancel={() => setConfirm(null)}
|
|
1738
|
+
onConfirm={() => {
|
|
1739
|
+
if (confirm?.kind !== "renameProject") {
|
|
1740
|
+
setConfirm(null);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
const { sourceName, targetName } = confirm;
|
|
1744
|
+
if (dismissRenameImpactConfirm) {
|
|
1745
|
+
try {
|
|
1746
|
+
globalThis.localStorage?.setItem(
|
|
1747
|
+
RENAME_IMPACT_CONFIRM_STORAGE_KEY,
|
|
1748
|
+
"1",
|
|
1749
|
+
);
|
|
1750
|
+
} catch {}
|
|
1751
|
+
}
|
|
1752
|
+
setConfirm(null);
|
|
1753
|
+
void post({
|
|
1754
|
+
type: "renameProjectMetadata",
|
|
1755
|
+
sourceName,
|
|
1756
|
+
targetName,
|
|
1757
|
+
});
|
|
1758
|
+
}}
|
|
1759
|
+
/>
|
|
957
1760
|
<DashboardConfirmModal
|
|
958
1761
|
open={confirm?.kind === "purgeProject"}
|
|
959
1762
|
message={s.projectRemoveConfirm}
|
|
@@ -984,9 +1787,15 @@ export function SettingsTagsProjectsSection({
|
|
|
984
1787
|
onChange: setRememberConcurrentChoice,
|
|
985
1788
|
}}
|
|
986
1789
|
onDismiss={cancelTrackingConflict}
|
|
987
|
-
onTertiary={() =>
|
|
988
|
-
|
|
989
|
-
|
|
1790
|
+
onTertiary={() =>
|
|
1791
|
+
void resolveConflictAndStart("parallel", rememberConcurrentChoice)
|
|
1792
|
+
}
|
|
1793
|
+
onSecondary={() =>
|
|
1794
|
+
void resolveConflictAndStart("pause", rememberConcurrentChoice)
|
|
1795
|
+
}
|
|
1796
|
+
onPrimary={() =>
|
|
1797
|
+
void resolveConflictAndStart("finish", rememberConcurrentChoice)
|
|
1798
|
+
}
|
|
990
1799
|
/>
|
|
991
1800
|
</section>
|
|
992
1801
|
);
|