@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,26 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ThemeProvider } from "@/components/ThemeProvider";
|
|
5
|
+
import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
|
|
6
|
+
|
|
7
|
+
describe("ThemeToggle", () => {
|
|
8
|
+
it("bascule le thème au clic", async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<ThemeProvider>
|
|
12
|
+
<ThemeToggle lang="fr" />
|
|
13
|
+
</ThemeProvider>
|
|
14
|
+
);
|
|
15
|
+
const btn = screen.getByRole("button");
|
|
16
|
+
await waitFor(() => {
|
|
17
|
+
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
const before = btn.getAttribute("aria-label");
|
|
20
|
+
await user.click(btn);
|
|
21
|
+
const after = btn.getAttribute("aria-label");
|
|
22
|
+
expect(before).toBeTruthy();
|
|
23
|
+
expect(after).toBeTruthy();
|
|
24
|
+
expect(before).not.toBe(after);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useKronosysTheme } from "@/components/ThemeProvider";
|
|
5
|
+
|
|
6
|
+
export function ThemeToggle({
|
|
7
|
+
lang,
|
|
8
|
+
className = "",
|
|
9
|
+
}: {
|
|
10
|
+
lang: "en" | "fr";
|
|
11
|
+
className?: string;
|
|
12
|
+
}) {
|
|
13
|
+
const { theme, toggleTheme, mounted } = useKronosysTheme();
|
|
14
|
+
const isDark = theme === "dark";
|
|
15
|
+
const aria =
|
|
16
|
+
lang === "fr"
|
|
17
|
+
? isDark
|
|
18
|
+
? "Passer au thème clair"
|
|
19
|
+
: "Passer au thème foncé"
|
|
20
|
+
: isDark
|
|
21
|
+
? "Switch to light theme"
|
|
22
|
+
: "Switch to dark theme";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
className={`inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 ${className}`}
|
|
28
|
+
aria-label={aria}
|
|
29
|
+
title={aria}
|
|
30
|
+
onClick={() => toggleTheme()}
|
|
31
|
+
disabled={!mounted}
|
|
32
|
+
>
|
|
33
|
+
{isDark ? <Sun size={20} aria-hidden /> : <Moon size={20} aria-hidden />}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
|
|
4
|
+
|
|
5
|
+
/** Liens internes de type `[libellé](/path#ancre)` dans le texte du guide. */
|
|
6
|
+
const INTERNAL_LINK = /\[([^\]]+)\]\((\/[^)]+)\)/g;
|
|
7
|
+
|
|
8
|
+
const linkClassName =
|
|
9
|
+
"font-medium text-violet-700 underline decoration-violet-500/50 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:hover:text-violet-100";
|
|
10
|
+
|
|
11
|
+
function renderBoldFragment(s: string, keyPrefix: string): ReactNode[] {
|
|
12
|
+
const segs = s.split("**");
|
|
13
|
+
return segs.map((seg, i) =>
|
|
14
|
+
i % 2 === 1 ? (
|
|
15
|
+
<strong key={`${keyPrefix}-b${i}`}>{seg}</strong>
|
|
16
|
+
) : (
|
|
17
|
+
<span key={`${keyPrefix}-b${i}`}>{seg}</span>
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Props = Readonly<{
|
|
23
|
+
line: string;
|
|
24
|
+
/** Conserve `?session=` comme les autres liens de l’appli. */
|
|
25
|
+
sessionId?: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Applique le gras `**` par paires et les liens internes `[texte](/chemin#ancre)`.
|
|
30
|
+
*/
|
|
31
|
+
export function UserGuideBodyText({ line, sessionId }: Props): ReactNode {
|
|
32
|
+
const matches = [...line.matchAll(INTERNAL_LINK)];
|
|
33
|
+
if (matches.length === 0) {
|
|
34
|
+
return <>{renderBoldFragment(line, "u0")}</>;
|
|
35
|
+
}
|
|
36
|
+
const out: ReactNode[] = [];
|
|
37
|
+
let pos = 0;
|
|
38
|
+
let k = 0;
|
|
39
|
+
for (const m of matches) {
|
|
40
|
+
if (m.index! > pos) {
|
|
41
|
+
out.push(...renderBoldFragment(line.slice(pos, m.index), `u${k++}`));
|
|
42
|
+
}
|
|
43
|
+
const rawHref = m[2];
|
|
44
|
+
const safe = rawHref.startsWith("/") && !rawHref.startsWith("//");
|
|
45
|
+
if (safe) {
|
|
46
|
+
const li = k++;
|
|
47
|
+
const href = withDashboardSessionParam(rawHref, sessionId ?? null);
|
|
48
|
+
out.push(
|
|
49
|
+
<Link key={`a${li}`} href={href} className={linkClassName}>
|
|
50
|
+
{renderBoldFragment(m[1], `in${li}`)}
|
|
51
|
+
</Link>
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
out.push(<span key={`raw${k}`}>{m[0]}</span>);
|
|
55
|
+
}
|
|
56
|
+
pos = m.index! + m[0].length;
|
|
57
|
+
}
|
|
58
|
+
if (pos < line.length) {
|
|
59
|
+
out.push(...renderBoldFragment(line.slice(pos), `u${k++}`));
|
|
60
|
+
}
|
|
61
|
+
return <>{out}</>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, GitBranch } from "lucide-react";
|
|
4
|
+
import { DashboardCollapsibleSection } from "@/components/dashboard/DashboardCollapsibleSection";
|
|
5
|
+
import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
|
|
6
|
+
import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
|
|
7
|
+
import { openPlainTextInNewTab } from "@/lib/openPlainTextInNewTab";
|
|
8
|
+
import type { GitRepoStatisticsPayload } from "@/lib/kronosysApi";
|
|
9
|
+
|
|
10
|
+
function StatCell({
|
|
11
|
+
label,
|
|
12
|
+
value,
|
|
13
|
+
helpAria,
|
|
14
|
+
helpBody,
|
|
15
|
+
}: {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string | number;
|
|
18
|
+
helpAria: string;
|
|
19
|
+
helpBody: string;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="rounded-lg border border-zinc-200 bg-zinc-100/80 px-3 py-2 dark:border-zinc-800/80 dark:bg-zinc-950/40">
|
|
23
|
+
<div className="flex min-h-5 items-center gap-0.5">
|
|
24
|
+
<span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
25
|
+
{label}
|
|
26
|
+
</span>
|
|
27
|
+
<InlineMetricHelpTrigger ariaLabel={helpAria} body={helpBody} />
|
|
28
|
+
</div>
|
|
29
|
+
<div className="mt-0.5 tabular-nums text-lg font-semibold text-zinc-900 dark:text-zinc-100">{value}</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function WorkspaceGitRepoCard({
|
|
35
|
+
git,
|
|
36
|
+
t,
|
|
37
|
+
lang,
|
|
38
|
+
}: {
|
|
39
|
+
git: GitRepoStatisticsPayload | undefined;
|
|
40
|
+
t: DashboardStrings;
|
|
41
|
+
lang: Lang;
|
|
42
|
+
}) {
|
|
43
|
+
if (!git?.isGitRepo) {
|
|
44
|
+
return (
|
|
45
|
+
<DashboardCollapsibleSection
|
|
46
|
+
sectionAriaLabel={t.gitRepoCardTitle}
|
|
47
|
+
title={
|
|
48
|
+
<span className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
|
49
|
+
<GitBranch className="text-sky-400/90" size={18} aria-hidden />
|
|
50
|
+
{t.gitRepoCardTitle}
|
|
51
|
+
</span>
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
<p className="text-sm text-zinc-500">{t.gitRepoNotARepo}</p>
|
|
55
|
+
</DashboardCollapsibleSection>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const branch = git.currentBranch ?? "—";
|
|
60
|
+
const locals = git.localBranchCount ?? 0;
|
|
61
|
+
const remotes = git.remoteBranchCount ?? 0;
|
|
62
|
+
const onHead = git.commitsOnHead ?? 0;
|
|
63
|
+
const allRefs = git.commitsAllRefs;
|
|
64
|
+
const stashes = git.stashCount ?? 0;
|
|
65
|
+
const graph = git.logGraphLines ?? [];
|
|
66
|
+
const paths = git.trackedPathsSample ?? [];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<DashboardCollapsibleSection
|
|
70
|
+
sectionAriaLabel={t.gitRepoCardTitle}
|
|
71
|
+
title={
|
|
72
|
+
<span className="flex flex-wrap items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
|
73
|
+
<GitBranch className="text-sky-400/90" size={18} aria-hidden />
|
|
74
|
+
{t.gitRepoCardTitle}
|
|
75
|
+
</span>
|
|
76
|
+
}
|
|
77
|
+
headerTrailing={<InlineMetricHelpTrigger ariaLabel={t.gitRepoCardHelpAria} body={t.gitRepoCardHelpBody} />}
|
|
78
|
+
>
|
|
79
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-500">
|
|
80
|
+
<span className="font-medium text-zinc-600 dark:text-zinc-400">{t.gitRepoCurrentBranchLabel}</span>{" "}
|
|
81
|
+
<span className="font-mono text-zinc-800 dark:text-zinc-200">{branch}</span>
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
85
|
+
<StatCell
|
|
86
|
+
label={t.gitRepoBranchesLocalLabel}
|
|
87
|
+
value={locals}
|
|
88
|
+
helpAria={t.gitRepoBranchesLocalHelpAria}
|
|
89
|
+
helpBody={t.gitRepoBranchesLocalHelpBody}
|
|
90
|
+
/>
|
|
91
|
+
<StatCell
|
|
92
|
+
label={t.gitRepoBranchesRemoteLabel}
|
|
93
|
+
value={remotes}
|
|
94
|
+
helpAria={t.gitRepoBranchesRemoteHelpAria}
|
|
95
|
+
helpBody={t.gitRepoBranchesRemoteHelpBody}
|
|
96
|
+
/>
|
|
97
|
+
<StatCell
|
|
98
|
+
label={t.gitRepoCommitsHeadLabel}
|
|
99
|
+
value={onHead}
|
|
100
|
+
helpAria={t.gitRepoCommitsHeadHelpAria}
|
|
101
|
+
helpBody={t.gitRepoCommitsHeadHelpBody}
|
|
102
|
+
/>
|
|
103
|
+
<StatCell
|
|
104
|
+
label={t.gitRepoCommitsAllLabel}
|
|
105
|
+
value={typeof allRefs === "number" ? allRefs : "—"}
|
|
106
|
+
helpAria={t.gitRepoCommitsAllHelpAria}
|
|
107
|
+
helpBody={t.gitRepoCommitsAllHelpBody}
|
|
108
|
+
/>
|
|
109
|
+
<StatCell
|
|
110
|
+
label={t.gitRepoStashesLabel}
|
|
111
|
+
value={stashes}
|
|
112
|
+
helpAria={t.gitRepoStashesHelpAria}
|
|
113
|
+
helpBody={t.gitRepoStashesHelpBody}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
|
118
|
+
<div>
|
|
119
|
+
<div className="mb-1 flex min-h-5 flex-wrap items-center gap-x-0.5 gap-y-0">
|
|
120
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500">{t.gitRepoGraphTitle}</span>
|
|
121
|
+
<InlineMetricHelpTrigger ariaLabel={t.gitRepoGraphHelpAria} body={t.gitRepoGraphHelpBody} />
|
|
122
|
+
</div>
|
|
123
|
+
{graph.length > 0 ? (
|
|
124
|
+
<p className="mb-1.5 text-[0.65rem] leading-snug text-zinc-500">{t.gitRepoGraphPreviewNote}</p>
|
|
125
|
+
) : null}
|
|
126
|
+
{graph.length === 0 ? (
|
|
127
|
+
<p className="text-sm text-zinc-500">{t.gitRepoGraphEmpty}</p>
|
|
128
|
+
) : (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
title={t.gitRepoGraphOpenFullTitle}
|
|
132
|
+
aria-label={t.gitRepoGraphOpenFullAria}
|
|
133
|
+
onClick={() =>
|
|
134
|
+
openPlainTextInNewTab({
|
|
135
|
+
documentTitle: t.gitRepoGraphDetailDocTitle,
|
|
136
|
+
heading: t.gitRepoGraphTitle,
|
|
137
|
+
body: graph.join("\n"),
|
|
138
|
+
lang,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
className="group relative max-h-28 w-full cursor-pointer overflow-auto rounded-lg border border-zinc-200 bg-zinc-100/90 p-2 text-left text-[0.6rem] leading-snug text-zinc-700 transition-colors hover:border-zinc-400 hover:bg-zinc-200/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800/80 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-600/90 dark:hover:bg-zinc-950/80 dark:focus-visible:ring-offset-zinc-950"
|
|
142
|
+
>
|
|
143
|
+
<ExternalLink
|
|
144
|
+
className="pointer-events-none absolute top-1.5 right-1.5 z-10 text-zinc-500 opacity-70 group-hover:text-sky-600 group-hover:opacity-100 dark:group-hover:text-sky-400/90"
|
|
145
|
+
size={12}
|
|
146
|
+
strokeWidth={2}
|
|
147
|
+
aria-hidden
|
|
148
|
+
/>
|
|
149
|
+
<pre className="pointer-events-none m-0 whitespace-pre wrap-normal pr-5 font-mono">{graph.join("\n")}</pre>
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
<div>
|
|
154
|
+
<div className="mb-1 flex min-h-5 flex-wrap items-center gap-x-0.5 gap-y-0">
|
|
155
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500">{t.gitRepoTreeTitle}</span>
|
|
156
|
+
<InlineMetricHelpTrigger ariaLabel={t.gitRepoTreeHelpAria} body={t.gitRepoTreeHelpBody} />
|
|
157
|
+
</div>
|
|
158
|
+
{paths.length > 0 ? (
|
|
159
|
+
<p className="mb-1.5 text-[0.65rem] leading-snug text-zinc-500">{t.gitRepoTreePreviewNote}</p>
|
|
160
|
+
) : null}
|
|
161
|
+
{paths.length === 0 ? (
|
|
162
|
+
<p className="text-sm text-zinc-500">{t.gitRepoTreeEmpty}</p>
|
|
163
|
+
) : (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
title={t.gitRepoTreeOpenFullTitle}
|
|
167
|
+
aria-label={t.gitRepoTreeOpenFullAria}
|
|
168
|
+
onClick={() =>
|
|
169
|
+
openPlainTextInNewTab({
|
|
170
|
+
documentTitle: t.gitRepoTreeDetailDocTitle,
|
|
171
|
+
heading: t.gitRepoTreeTitle,
|
|
172
|
+
body: paths.join("\n"),
|
|
173
|
+
lang,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
className="group relative max-h-28 w-full cursor-pointer overflow-auto rounded-lg border border-zinc-200 bg-zinc-100/90 p-2 text-left text-[0.6rem] leading-snug text-zinc-700 transition-colors hover:border-zinc-400 hover:bg-zinc-200/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800/80 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-600/90 dark:hover:bg-zinc-950/80 dark:focus-visible:ring-offset-zinc-950"
|
|
177
|
+
>
|
|
178
|
+
<ExternalLink
|
|
179
|
+
className="pointer-events-none absolute top-1.5 right-1.5 z-10 text-zinc-500 opacity-70 group-hover:text-sky-600 group-hover:opacity-100 dark:group-hover:text-sky-400/90"
|
|
180
|
+
size={12}
|
|
181
|
+
strokeWidth={2}
|
|
182
|
+
aria-hidden
|
|
183
|
+
/>
|
|
184
|
+
<pre className="pointer-events-none m-0 whitespace-pre font-mono pr-5">{paths.join("\n")}</pre>
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</DashboardCollapsibleSection>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles communs : champs plus hauts (~+50 %), texte ~+30 % (text-lg vs text-sm),
|
|
3
|
+
* padding généreux et leading-relaxed pour éviter l’effet serré.
|
|
4
|
+
*/
|
|
5
|
+
export const TASK_BODY_INPUT_CLASS =
|
|
6
|
+
"min-h-[3rem] w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3.5 py-3.5 text-lg leading-relaxed text-zinc-100 outline-none placeholder:text-zinc-600 ring-violet-500/30 focus:ring-2";
|
|
7
|
+
|
|
8
|
+
/** Champ « nouvelle tâche » : ligne de saisie sans encadré (soulignement seulement), texte blanc. */
|
|
9
|
+
export const TASK_FOCUS_LAUNCHER_INPUT_CLASS =
|
|
10
|
+
"min-h-[2.75rem] w-full border-0 border-b border-zinc-400 bg-transparent px-0 py-2 text-xl leading-relaxed text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-600 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-zinc-200";
|
|
11
|
+
|
|
12
|
+
/** Même champ en rangée avec les boutons (minuteur, démarrer) : hauteur h-10, `flex-1` sans `w-full`. */
|
|
13
|
+
export const TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS =
|
|
14
|
+
"h-10 min-h-10 min-w-0 flex-1 border-0 border-b border-zinc-400 bg-transparent px-0 text-xl leading-snug text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-600 dark:text-white dark:placeholder:text-zinc-500 dark:focus:border-zinc-200";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Déclencheur date-heure (tâche passée, popover thémée) : violet / zinc comme le reste du tableau de bord.
|
|
18
|
+
*/
|
|
19
|
+
export const TASK_PAST_DATETIME_TRIGGER_CLASS =
|
|
20
|
+
"h-9 w-auto min-w-[8.5rem] max-w-[13rem] shrink-0 rounded-lg border border-violet-500/45 bg-violet-500/10 px-2 py-1 font-mono text-xs tabular-nums text-zinc-800 shadow-none outline-none transition-[border-color,background-color,box-shadow] [color-scheme:light] focus-visible:border-violet-500/75 focus-visible:bg-violet-500/15 focus-visible:ring-2 focus-visible:ring-violet-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] dark:border-violet-400/40 dark:bg-violet-600/18 dark:text-zinc-100 dark:[color-scheme:dark] dark:focus-visible:border-violet-400/60 dark:focus-visible:bg-violet-600/26 dark:focus-visible:ring-violet-400/25 dark:focus-visible:ring-offset-[var(--background)]";
|
|
21
|
+
|
|
22
|
+
/** Titre tâche en session (lecture) : volontairement plus petit que le champ « nouvelle tâche ». */
|
|
23
|
+
export const TASK_ACTIVE_TITLE_READ_CLASS =
|
|
24
|
+
"text-base font-medium leading-snug [scrollbar-width:thin]";
|
|
25
|
+
|
|
26
|
+
/** Édition du titre de la tâche active (même ligne / hauteur que la rangée d’actions). */
|
|
27
|
+
export const TASK_ACTIVE_TITLE_EDIT_INPUT_CLASS =
|
|
28
|
+
"h-10 w-full min-w-0 border-0 border-b border-zinc-400 bg-transparent px-0 text-base font-medium leading-snug text-zinc-900 outline-none ring-0 placeholder:text-zinc-500 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-500 dark:text-white dark:placeholder:text-zinc-600 dark:focus:border-zinc-200";
|
|
29
|
+
|
|
30
|
+
/** Édition du titre de tâche (bordure violette). */
|
|
31
|
+
export const TASK_TITLE_EDIT_INPUT_CLASS =
|
|
32
|
+
"min-h-[3rem] w-full rounded-lg border border-violet-500/50 bg-zinc-950 px-3.5 py-3.5 text-lg leading-relaxed text-zinc-100 outline-none focus:ring-2 focus:ring-violet-500";
|
|
33
|
+
|
|
34
|
+
/** Sous-tâche : champ d’édition dans la ligne (aligné sur la ligne titre). */
|
|
35
|
+
export const TASK_SUBTASK_ROW_INPUT_CLASS =
|
|
36
|
+
"h-10 w-full min-w-0 border-0 border-b border-violet-500/70 bg-transparent px-0 text-base font-medium leading-snug text-zinc-900 outline-none ring-0 placeholder:text-zinc-500 focus:border-violet-500 focus:ring-0 rounded-none dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-violet-400";
|
|
37
|
+
|
|
38
|
+
/** Nouvelle sous-tâche (ligne d’ajout). */
|
|
39
|
+
export const TASK_SUBTASK_NEW_INPUT_CLASS =
|
|
40
|
+
"min-h-[3rem] min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-950 px-3.5 py-3.5 text-lg leading-relaxed text-zinc-100 outline-none focus:ring-2 focus:ring-violet-500/50";
|
|
41
|
+
|
|
42
|
+
/** Nouvelle sous-tâche : même principe que la ligne de titre (soulignement, Entrée pour valider). */
|
|
43
|
+
export const TASK_SUBTASK_NEW_INLINE_CLASS =
|
|
44
|
+
"min-h-10 w-full border-0 border-b border-zinc-400/90 bg-transparent px-0 py-2 text-base font-medium leading-snug text-zinc-900 outline-none placeholder:text-zinc-500 focus:border-zinc-600 focus:ring-0 rounded-none dark:border-zinc-600/80 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-zinc-400";
|
|
45
|
+
|
|
46
|
+
/** Hauteur commune pastilles / suggestions tag (~30px, +50 % vs 20px). */
|
|
47
|
+
export const TAG_ROW_MIN_H = "min-h-[1.875rem]";
|
|
48
|
+
|
|
49
|
+
/** Pastille d’étiquette (lecture seule ou cliquable). */
|
|
50
|
+
export const TAG_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-zinc-300 bg-zinc-100 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-zinc-800 dark:border-zinc-600/65 dark:bg-zinc-800/55 dark:text-zinc-400`;
|
|
51
|
+
|
|
52
|
+
/** Étiquette appliquée à la tâche en cours (liste sous le titre, tâche active). */
|
|
53
|
+
export const TAG_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-emerald-500/45 bg-emerald-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-emerald-900 shadow-[0_0_12px_rgba(16,185,129,0.08)] dark:border-emerald-500/55 dark:bg-emerald-950/45 dark:text-emerald-200 dark:shadow-[0_0_12px_rgba(16,185,129,0.12)]`;
|
|
54
|
+
|
|
55
|
+
/** Pastille avec suppression (édition). */
|
|
56
|
+
export const TAG_CHIP_REMOVABLE_CLASS = `${TAG_CHIP_CLASS} !cursor-pointer hover:border-zinc-400 hover:bg-zinc-200/90 hover:text-zinc-950 dark:hover:border-zinc-500 dark:hover:bg-zinc-700/45 dark:hover:text-zinc-200`;
|
|
57
|
+
|
|
58
|
+
/** Même style « allumé » pour l’édition avec suppression. */
|
|
59
|
+
export const TAG_CHIP_APPLIED_REMOVABLE_CLASS = `${TAG_CHIP_APPLIED_CLASS} !cursor-pointer hover:border-emerald-600/55 hover:bg-emerald-100 dark:hover:border-emerald-400/70 dark:hover:bg-emerald-900/40`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Étiquette de portée projet (`projet#local`, souvent implicite via `@projet#…` dans le titre) :
|
|
63
|
+
* même famille verte que {@link TAG_CHIP_APPLIED_CLASS}, sans fond — lecture seule.
|
|
64
|
+
*/
|
|
65
|
+
export const TAG_CHIP_APPLIED_SCOPED_READONLY_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-emerald-600/55 bg-transparent px-2.5 py-1 text-[0.94rem] font-medium leading-none text-emerald-800 dark:border-emerald-500/60 dark:bg-transparent dark:text-emerald-300`;
|
|
66
|
+
|
|
67
|
+
/** Variante interactive du style portée projet (sélecteur d’étiquettes). */
|
|
68
|
+
export const TAG_CHIP_APPLIED_SCOPED_TOGGLE_CLASS = `${TAG_CHIP_APPLIED_SCOPED_READONLY_CLASS} !cursor-pointer transition-colors hover:border-emerald-700/65 hover:bg-emerald-500/10 dark:hover:border-emerald-400/75 dark:hover:bg-emerald-400/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/35 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900`;
|
|
69
|
+
|
|
70
|
+
/** Ligne sous le titre : pastilles d’étiquettes. */
|
|
71
|
+
export const TASK_TAGS_ROW_CLASS =
|
|
72
|
+
"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 [&>*]:max-w-full";
|
|
73
|
+
|
|
74
|
+
/** Suggestion d’étiquette enregistrée (même échelle que les pastilles). */
|
|
75
|
+
export const TAG_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-pointer select-none rounded border border-zinc-300 bg-zinc-100 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-zinc-700 hover:border-emerald-500/50 hover:bg-emerald-50 hover:text-emerald-900 dark:border-zinc-600/50 dark:bg-zinc-800/40 dark:text-zinc-400 dark:hover:border-emerald-700/50 dark:hover:bg-emerald-950/35 dark:hover:text-emerald-200/90`;
|
|
76
|
+
|
|
77
|
+
/** Suggestion de projet enregistré (`@nom`) : même rendu « éteint » que les tags tant que le projet n’est pas appliqué. */
|
|
78
|
+
export const PROJECT_SUGGEST_CHIP_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-pointer select-none rounded border border-zinc-300 bg-zinc-100 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-zinc-700 hover:border-sky-500/50 hover:bg-sky-50 hover:text-sky-900 dark:border-zinc-600/50 dark:bg-zinc-800/40 dark:text-zinc-400 dark:hover:border-sky-700/50 dark:hover:bg-sky-950/35 dark:hover:text-sky-200/90`;
|
|
79
|
+
|
|
80
|
+
/** Pastille projet appliquée (ligne sous le titre). */
|
|
81
|
+
export const PROJECT_CHIP_APPLIED_CLASS = `inline-flex items-center gap-1 ${TAG_ROW_MIN_H} cursor-default select-none rounded border border-sky-500/50 bg-sky-50 px-2.5 py-1 text-[0.94rem] font-medium leading-none text-sky-900 dark:border-sky-500/55 dark:bg-sky-950/40 dark:text-sky-100`;
|
|
82
|
+
|
|
83
|
+
// --- Rendu « texte » (tâches : sans boîtes type pastille) ----------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Étiquette affichée sur une tâche, lecture seule. */
|
|
86
|
+
export const TAG_INLINE_APPLIED_READONLY =
|
|
87
|
+
"text-[0.9375rem] font-medium text-emerald-800 dark:text-emerald-200";
|
|
88
|
+
|
|
89
|
+
/** Étiquette de portée projet sur une tâche, lecture seule. */
|
|
90
|
+
export const TAG_INLINE_APPLIED_SCOPED_READONLY =
|
|
91
|
+
"text-[0.9375rem] font-medium text-emerald-800 border-b border-dotted border-emerald-500/55 dark:text-emerald-200";
|
|
92
|
+
|
|
93
|
+
/** Étiquette de portée projet sur une tâche, retirable. */
|
|
94
|
+
export const TAG_INLINE_APPLIED_SCOPED_REMOVABLE =
|
|
95
|
+
"inline-flex cursor-pointer items-baseline gap-1 rounded px-0.5 text-[0.9375rem] font-medium text-emerald-800 border-b border-dotted border-emerald-500/55 transition-colors hover:bg-emerald-500/10 dark:text-emerald-200 dark:hover:bg-emerald-400/10";
|
|
96
|
+
|
|
97
|
+
/** Étiquette appliquée retirable (édition). */
|
|
98
|
+
export const TAG_INLINE_APPLIED_REMOVABLE =
|
|
99
|
+
"inline-flex cursor-pointer items-baseline gap-1 rounded px-0.5 text-[0.9375rem] font-medium text-emerald-800 transition-colors hover:bg-emerald-500/10 dark:text-emerald-200 dark:hover:bg-emerald-400/10";
|
|
100
|
+
|
|
101
|
+
/** Étiquette hors variante « applied » (lecture seule). */
|
|
102
|
+
export const TAG_INLINE_NEUTRAL_READONLY =
|
|
103
|
+
"text-[0.9375rem] font-medium text-zinc-700 dark:text-zinc-300";
|
|
104
|
+
|
|
105
|
+
/** Étiquette hors variante « applied », retirable. */
|
|
106
|
+
export const TAG_INLINE_NEUTRAL_REMOVABLE =
|
|
107
|
+
"inline-flex cursor-pointer items-baseline gap-1 rounded px-0.5 text-[0.9375rem] font-medium text-zinc-700 transition-colors hover:bg-zinc-500/10 dark:text-zinc-300 dark:hover:bg-zinc-600/20";
|
|
108
|
+
|
|
109
|
+
/** Raccourci étiquette inactif (ligne de suggestions). */
|
|
110
|
+
export const TAG_INLINE_SUGGEST =
|
|
111
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-medium text-zinc-600 transition-colors hover:text-violet-700 dark:text-zinc-400 dark:hover:text-violet-300";
|
|
112
|
+
|
|
113
|
+
/** Raccourci étiquette actif. */
|
|
114
|
+
export const TAG_INLINE_SUGGEST_SELECTED =
|
|
115
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-semibold text-emerald-800 underline decoration-emerald-500/80 decoration-2 underline-offset-4 dark:text-emerald-200";
|
|
116
|
+
|
|
117
|
+
/** Raccourci étiquette actif, portée projet (`projet#code`). */
|
|
118
|
+
export const TAG_INLINE_SUGGEST_SCOPED_SELECTED =
|
|
119
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-semibold text-emerald-800 border-b-2 border-dotted border-emerald-600 dark:text-emerald-200";
|
|
120
|
+
|
|
121
|
+
/** Projet appliqué en style texte (ex. légendes compactes hors carte tâche). */
|
|
122
|
+
export const PROJECT_INLINE_APPLIED =
|
|
123
|
+
"text-[0.9375rem] font-semibold text-sky-900 dark:text-sky-100";
|
|
124
|
+
|
|
125
|
+
/** Raccourci projet inactif. */
|
|
126
|
+
export const PROJECT_INLINE_SUGGEST =
|
|
127
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-medium text-zinc-600 transition-colors hover:text-sky-800 dark:text-zinc-400 dark:hover:text-sky-200";
|
|
128
|
+
|
|
129
|
+
/** Raccourci projet actif. */
|
|
130
|
+
export const PROJECT_INLINE_SUGGEST_SELECTED =
|
|
131
|
+
"shrink-0 rounded-sm px-0.5 py-0.5 text-left text-[0.9375rem] font-semibold text-sky-900 underline decoration-sky-500/80 decoration-2 underline-offset-4 dark:text-sky-100";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Champ d’ajout rapide `#` / `@` à côté des étiquettes : même taille et graisse que
|
|
135
|
+
* {@link TAG_INLINE_APPLIED_READONLY}, interligne confortable (`leading-relaxed`), fond discret
|
|
136
|
+
* proche de la teinte des étiquettes appliquées.
|
|
137
|
+
*/
|
|
138
|
+
export const TAG_PROJECT_INLINE_ADD_INPUT_CLASS =
|
|
139
|
+
"box-border min-h-9 min-w-[5.5rem] w-full max-w-full rounded-md border border-zinc-300/85 bg-emerald-50/35 px-2 py-1.5 text-[0.9375rem] font-medium leading-relaxed text-zinc-800 shadow-sm outline-none placeholder:font-normal placeholder:text-zinc-400 focus-visible:border-emerald-500/55 focus-visible:bg-white focus-visible:ring-2 focus-visible:ring-emerald-500/25 dark:border-zinc-600 dark:bg-emerald-950/20 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:border-emerald-500/50 dark:focus-visible:bg-zinc-950 dark:focus-visible:ring-emerald-400/20";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useState, type CSSProperties, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
const VIEW_MARGIN = 10;
|
|
6
|
+
const GAP_PX = 4;
|
|
7
|
+
|
|
8
|
+
export type AnchoredFloatingAlign = "start" | "end";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Position `fixed` pour un panneau en portail (évite le clip des ancêtres `overflow-*`
|
|
12
|
+
* et les barres de défilement sur les rangées scrollables).
|
|
13
|
+
*/
|
|
14
|
+
export function useAnchoredFloatingPortalStyle(
|
|
15
|
+
open: boolean,
|
|
16
|
+
triggerRef: RefObject<HTMLElement | null>,
|
|
17
|
+
panelRef: RefObject<HTMLElement | null>,
|
|
18
|
+
opts: { align: AnchoredFloatingAlign; maxWidthRem: number }
|
|
19
|
+
): CSSProperties | undefined {
|
|
20
|
+
const [style, setStyle] = useState<CSSProperties | undefined>(undefined);
|
|
21
|
+
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
if (!open) {
|
|
24
|
+
setStyle(undefined);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const apply = () => {
|
|
29
|
+
const trig = triggerRef.current;
|
|
30
|
+
if (!trig) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const r = trig.getBoundingClientRect();
|
|
34
|
+
const vw = globalThis.innerWidth;
|
|
35
|
+
const vh = globalThis.innerHeight;
|
|
36
|
+
const w = Math.min(opts.maxWidthRem * 16, vw - 2 * VIEW_MARGIN);
|
|
37
|
+
let left = opts.align === "end" ? r.right - w : r.left;
|
|
38
|
+
left = Math.max(VIEW_MARGIN, Math.min(left, vw - w - VIEW_MARGIN));
|
|
39
|
+
let top = r.bottom + GAP_PX;
|
|
40
|
+
const panel = panelRef.current;
|
|
41
|
+
if (panel) {
|
|
42
|
+
const ph = panel.getBoundingClientRect().height;
|
|
43
|
+
if (top + ph > vh - VIEW_MARGIN) {
|
|
44
|
+
top = Math.max(VIEW_MARGIN, r.top - GAP_PX - ph);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (top < VIEW_MARGIN) {
|
|
48
|
+
top = VIEW_MARGIN;
|
|
49
|
+
}
|
|
50
|
+
setStyle({
|
|
51
|
+
position: "fixed",
|
|
52
|
+
top,
|
|
53
|
+
left,
|
|
54
|
+
width: w,
|
|
55
|
+
zIndex: 80,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
apply();
|
|
60
|
+
const raf = requestAnimationFrame(apply);
|
|
61
|
+
globalThis.addEventListener("scroll", apply, true);
|
|
62
|
+
globalThis.addEventListener("resize", apply);
|
|
63
|
+
return () => {
|
|
64
|
+
cancelAnimationFrame(raf);
|
|
65
|
+
globalThis.removeEventListener("scroll", apply, true);
|
|
66
|
+
globalThis.removeEventListener("resize", apply);
|
|
67
|
+
};
|
|
68
|
+
}, [open, opts.align, opts.maxWidthRem, triggerRef, panelRef]);
|
|
69
|
+
|
|
70
|
+
return open ? style : undefined;
|
|
71
|
+
}
|