@tuturuuu/ui 0.14.0 → 0.17.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/CHANGELOG.md +96 -0
- package/biome.json +1 -1
- package/package.json +63 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/storefront/browse-panel.tsx +161 -0
- package/src/components/ui/storefront/bundle-selection-dialog.tsx +1 -1
- package/src/components/ui/storefront/cart-popover.tsx +1 -1
- package/src/components/ui/storefront/hero-panel.tsx +35 -30
- package/src/components/ui/storefront/listing-card.tsx +10 -5
- package/src/components/ui/storefront/merch-sections.tsx +7 -5
- package/src/components/ui/storefront/product-detail.tsx +79 -6
- package/src/components/ui/storefront/product-dialog.tsx +5 -1
- package/src/components/ui/storefront/storefront-surface.test.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.tsx +67 -63
- package/src/components/ui/storefront/types.ts +28 -0
- package/src/components/ui/storefront/utils.ts +4 -5
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +59 -1
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +37 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +60 -26
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/globals.css +1 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/vitest.config.ts +4 -0
|
@@ -8,13 +8,12 @@ import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group';
|
|
|
8
8
|
import { Separator } from '@tuturuuu/ui/separator';
|
|
9
9
|
import { Switch } from '@tuturuuu/ui/switch';
|
|
10
10
|
import { useTranslations } from 'next-intl';
|
|
11
|
+
import type { SidebarBehavior } from '../sidebar-context';
|
|
11
12
|
|
|
12
13
|
interface SidebarSettingsProps {
|
|
13
14
|
useSidebar: () => {
|
|
14
|
-
behavior:
|
|
15
|
-
handleBehaviorChange: (
|
|
16
|
-
behavior: 'expanded' | 'collapsed' | 'hover'
|
|
17
|
-
) => void;
|
|
15
|
+
behavior: SidebarBehavior;
|
|
16
|
+
handleBehaviorChange: (behavior: SidebarBehavior) => void;
|
|
18
17
|
localOverride: boolean;
|
|
19
18
|
setLocalOverride: (value: boolean) => void;
|
|
20
19
|
};
|
|
@@ -45,9 +44,9 @@ export default function SidebarSettings({ useSidebar }: SidebarSettingsProps) {
|
|
|
45
44
|
<RadioGroup
|
|
46
45
|
value={behavior}
|
|
47
46
|
onValueChange={(value) =>
|
|
48
|
-
handleBehaviorChange(value as
|
|
47
|
+
handleBehaviorChange(value as SidebarBehavior)
|
|
49
48
|
}
|
|
50
|
-
className="grid grid-cols-
|
|
49
|
+
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
|
51
50
|
>
|
|
52
51
|
<div>
|
|
53
52
|
<RadioGroupItem
|
|
@@ -84,6 +83,19 @@ export default function SidebarSettings({ useSidebar }: SidebarSettingsProps) {
|
|
|
84
83
|
{t('settings.preferences.sidebar_hover')}
|
|
85
84
|
</Label>
|
|
86
85
|
</div>
|
|
86
|
+
<div>
|
|
87
|
+
<RadioGroupItem
|
|
88
|
+
value="hidden"
|
|
89
|
+
id="hidden"
|
|
90
|
+
className="peer sr-only"
|
|
91
|
+
/>
|
|
92
|
+
<Label
|
|
93
|
+
htmlFor="hidden"
|
|
94
|
+
className="flex cursor-pointer flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
|
95
|
+
>
|
|
96
|
+
{t('settings.preferences.sidebar_hidden')}
|
|
97
|
+
</Label>
|
|
98
|
+
</div>
|
|
87
99
|
</RadioGroup>
|
|
88
100
|
</SettingItemTab>
|
|
89
101
|
|
|
@@ -64,6 +64,7 @@ export interface SettingsNavItem {
|
|
|
64
64
|
label: string;
|
|
65
65
|
icon: ComponentType<{ className?: string }>;
|
|
66
66
|
description?: string;
|
|
67
|
+
hideContentHeader?: boolean;
|
|
67
68
|
disabled?: boolean;
|
|
68
69
|
keywords?: string[];
|
|
69
70
|
aliases?: string[];
|
|
@@ -157,6 +158,7 @@ export function SettingsDialogShell({
|
|
|
157
158
|
allNavItems.find((item) => item.name === activeTab) ||
|
|
158
159
|
allNavItems.find((item) => !item.disabled) ||
|
|
159
160
|
allNavItems[0];
|
|
161
|
+
const showContentHeader = !activeItem?.hideContentHeader;
|
|
160
162
|
|
|
161
163
|
const ensureSearchEngine = useCallback(() => {
|
|
162
164
|
if (searchEngineFactory || searchEngineLoadRef.current) return;
|
|
@@ -511,18 +513,22 @@ export function SettingsDialogShell({
|
|
|
511
513
|
</header>
|
|
512
514
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
|
513
515
|
<div className="mx-auto w-full max-w-4xl space-y-6">
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
516
|
+
{showContentHeader && (
|
|
517
|
+
<>
|
|
518
|
+
<div className="space-y-1">
|
|
519
|
+
<h2 className="font-semibold text-lg tracking-tight">
|
|
520
|
+
{activeItem?.label}
|
|
521
|
+
</h2>
|
|
522
|
+
<p className="text-muted-foreground text-sm">
|
|
523
|
+
{activeItem?.description ||
|
|
524
|
+
t('settings.manage_settings', {
|
|
525
|
+
label: activeItem?.label?.toLowerCase() ?? '',
|
|
526
|
+
})}
|
|
527
|
+
</p>
|
|
528
|
+
</div>
|
|
529
|
+
<Separator />
|
|
530
|
+
</>
|
|
531
|
+
)}
|
|
526
532
|
{children}
|
|
527
533
|
</div>
|
|
528
534
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useLocalStorage } from '@tuturuuu/ui/hooks/use-local-storage';
|
|
4
|
+
import { getTuturuuuBrowserSharedCookieOptions } from '@tuturuuu/utils/shared-cookie';
|
|
4
5
|
import { setCookie } from 'cookies-next';
|
|
5
6
|
import {
|
|
6
7
|
type ComponentType,
|
|
@@ -19,7 +20,7 @@ export const SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME =
|
|
|
19
20
|
'sidebar-behavior-updated-at';
|
|
20
21
|
export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
21
22
|
|
|
22
|
-
export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
23
|
+
export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover' | 'hidden';
|
|
23
24
|
|
|
24
25
|
interface SidebarContextProps {
|
|
25
26
|
behavior: SidebarBehavior;
|
|
@@ -40,6 +41,10 @@ export const SIDEBAR_COOKIE_OPTIONS = {
|
|
|
40
41
|
path: '/',
|
|
41
42
|
} as const;
|
|
42
43
|
|
|
44
|
+
export function getSidebarCookieOptions() {
|
|
45
|
+
return getTuturuuuBrowserSharedCookieOptions(SIDEBAR_COOKIE_OPTIONS);
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
function parseSidebarBehaviorUpdatedAt(value: string | undefined | null) {
|
|
44
49
|
if (!value) return null;
|
|
45
50
|
|
|
@@ -73,6 +78,19 @@ type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
|
|
|
73
78
|
userChangeVersion: number;
|
|
74
79
|
}>;
|
|
75
80
|
|
|
81
|
+
function isEditableShortcutTarget(target: EventTarget | null) {
|
|
82
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
83
|
+
|
|
84
|
+
if (target.isContentEditable) return true;
|
|
85
|
+
|
|
86
|
+
const tagName = target.tagName.toLowerCase();
|
|
87
|
+
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Boolean(target.closest('[contenteditable="true"], [role="textbox"]'));
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
function useSidebarRemoteBehaviorBridge() {
|
|
77
95
|
const [RemoteBehaviorBridge, setRemoteBehaviorBridge] =
|
|
78
96
|
useState<SidebarRemoteBehaviorBridgeComponent | null>(null);
|
|
@@ -136,7 +154,7 @@ export const SidebarProvider = ({
|
|
|
136
154
|
setCookie(
|
|
137
155
|
SIDEBAR_BEHAVIOR_COOKIE_NAME,
|
|
138
156
|
newBehavior,
|
|
139
|
-
|
|
157
|
+
getSidebarCookieOptions()
|
|
140
158
|
);
|
|
141
159
|
|
|
142
160
|
if (!options.markLocalChange) return;
|
|
@@ -146,7 +164,7 @@ export const SidebarProvider = ({
|
|
|
146
164
|
setCookie(
|
|
147
165
|
SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME,
|
|
148
166
|
String(updatedAt),
|
|
149
|
-
|
|
167
|
+
getSidebarCookieOptions()
|
|
150
168
|
);
|
|
151
169
|
},
|
|
152
170
|
[]
|
|
@@ -175,6 +193,41 @@ export const SidebarProvider = ({
|
|
|
175
193
|
[applyBehavior, remoteBehavior, setLocalOverrideRaw]
|
|
176
194
|
);
|
|
177
195
|
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
198
|
+
if (
|
|
199
|
+
event.defaultPrevented ||
|
|
200
|
+
event.key.toLowerCase() !== 'b' ||
|
|
201
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
202
|
+
event.shiftKey ||
|
|
203
|
+
isEditableShortcutTarget(event.target)
|
|
204
|
+
) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (event.altKey) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
handleBehaviorChange('hidden');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
handleBehaviorChange(
|
|
216
|
+
behavior === 'expanded'
|
|
217
|
+
? 'collapsed'
|
|
218
|
+
: behavior === 'hidden'
|
|
219
|
+
? 'collapsed'
|
|
220
|
+
: 'expanded'
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
228
|
+
};
|
|
229
|
+
}, [behavior, handleBehaviorChange]);
|
|
230
|
+
|
|
178
231
|
return (
|
|
179
232
|
<SidebarContext.Provider
|
|
180
233
|
value={{
|
|
@@ -208,3 +261,5 @@ export const useSidebar = () => {
|
|
|
208
261
|
}
|
|
209
262
|
return context;
|
|
210
263
|
};
|
|
264
|
+
|
|
265
|
+
export const useOptionalSidebar = () => useContext(SidebarContext);
|
|
@@ -5,14 +5,16 @@ import {
|
|
|
5
5
|
useUserConfig,
|
|
6
6
|
} from '@tuturuuu/ui/hooks/use-user-config';
|
|
7
7
|
import { useEffect, useRef } from 'react';
|
|
8
|
-
|
|
9
|
-
type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
8
|
+
import type { SidebarBehavior } from './sidebar-context';
|
|
10
9
|
|
|
11
10
|
const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
12
11
|
const RECENT_LOCAL_BEHAVIOR_GRACE_MS = 5 * 60 * 1000;
|
|
13
12
|
|
|
14
13
|
const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
|
|
15
|
-
value === 'expanded' ||
|
|
14
|
+
value === 'expanded' ||
|
|
15
|
+
value === 'collapsed' ||
|
|
16
|
+
value === 'hover' ||
|
|
17
|
+
value === 'hidden';
|
|
16
18
|
|
|
17
19
|
interface SidebarRemoteBehaviorBridgeProps {
|
|
18
20
|
behavior: SidebarBehavior;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Progress } from '@tuturuuu/ui/progress';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
title?: string;
|
|
6
|
+
value?: string | number | null;
|
|
7
|
+
limit?: number;
|
|
8
|
+
displayValue?: React.ReactNode;
|
|
9
|
+
displayLimit?: React.ReactNode;
|
|
10
|
+
progress?: number;
|
|
11
|
+
href?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const StatisticCard = ({
|
|
17
|
+
title,
|
|
18
|
+
value,
|
|
19
|
+
limit,
|
|
20
|
+
displayValue,
|
|
21
|
+
displayLimit,
|
|
22
|
+
progress,
|
|
23
|
+
href,
|
|
24
|
+
className,
|
|
25
|
+
onClick,
|
|
26
|
+
}: Props) => {
|
|
27
|
+
const generateOuterColor = (enableHoverEffect: boolean) =>
|
|
28
|
+
`${
|
|
29
|
+
enableHoverEffect
|
|
30
|
+
? 'border-border hover:bg-foreground/[0.025] dark:hover:bg-foreground/5'
|
|
31
|
+
: 'border-border/50'
|
|
32
|
+
}`;
|
|
33
|
+
|
|
34
|
+
const progressValue =
|
|
35
|
+
progress !== undefined
|
|
36
|
+
? progress
|
|
37
|
+
: typeof value === 'number' && limit
|
|
38
|
+
? (value / limit) * 100
|
|
39
|
+
: 0;
|
|
40
|
+
|
|
41
|
+
const progressColor =
|
|
42
|
+
progressValue >= 100
|
|
43
|
+
? 'bg-dynamic-red'
|
|
44
|
+
: progressValue >= 90
|
|
45
|
+
? 'bg-dynamic-yellow'
|
|
46
|
+
: '';
|
|
47
|
+
|
|
48
|
+
const cardContent = (
|
|
49
|
+
<div className="flex h-full flex-col justify-between">
|
|
50
|
+
<div>
|
|
51
|
+
<div className="line-clamp-1 p-1 text-center font-semibold text-lg">
|
|
52
|
+
{title}
|
|
53
|
+
</div>
|
|
54
|
+
<div
|
|
55
|
+
className={`m-2 mt-0 line-clamp-1 rounded border border-border/30 bg-foreground/5 p-4 text-center font-bold text-2xl text-foreground ${
|
|
56
|
+
onClick || href
|
|
57
|
+
? 'transition-all duration-300 group-hover:rounded-lg'
|
|
58
|
+
: ''
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{displayValue ?? (value != null ? value : 'N/A')}
|
|
62
|
+
{(displayLimit || limit) && (
|
|
63
|
+
<span className="font-normal text-base text-muted-foreground">
|
|
64
|
+
{' '}
|
|
65
|
+
/ {displayLimit ?? limit}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
{(limit || displayLimit || progress !== undefined) && (
|
|
71
|
+
<div className="px-4 pb-4">
|
|
72
|
+
<Progress value={progressValue} indicatorClassName={progressColor} />
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (href)
|
|
79
|
+
return (
|
|
80
|
+
<Link
|
|
81
|
+
href={href}
|
|
82
|
+
onClick={onClick}
|
|
83
|
+
className={`group flex flex-col rounded-lg border transition-all duration-300 ${
|
|
84
|
+
onClick || href ? 'hover:rounded-xl' : 'cursor-default'
|
|
85
|
+
} ${generateOuterColor(!!onClick || !!href)} ${className || ''}`}
|
|
86
|
+
>
|
|
87
|
+
{cardContent}
|
|
88
|
+
</Link>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onClick}
|
|
95
|
+
className={`group flex flex-col rounded-lg border transition duration-300 ${
|
|
96
|
+
onClick || href ? 'hover:rounded-xl' : 'cursor-default'
|
|
97
|
+
} ${generateOuterColor(!!onClick || !!href)} ${className || ''}`}
|
|
98
|
+
>
|
|
99
|
+
{cardContent}
|
|
100
|
+
</button>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export default StatisticCard;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Structure } from './structure';
|
|
5
|
+
|
|
6
|
+
describe('Structure', () => {
|
|
7
|
+
it('removes sidebar chrome and desktop padding when the sidebar is hidden', () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<Structure
|
|
10
|
+
isCollapsed
|
|
11
|
+
setIsCollapsed={vi.fn()}
|
|
12
|
+
mobileHeader={<span>Mobile header</span>}
|
|
13
|
+
sidebarContent={<span>Sidebar content</span>}
|
|
14
|
+
sidebarHeader={<span>Sidebar header</span>}
|
|
15
|
+
sidebarHidden
|
|
16
|
+
>
|
|
17
|
+
<span>Page content</span>
|
|
18
|
+
</Structure>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByText('Page content')).toBeInTheDocument();
|
|
22
|
+
expect(screen.queryByText('Mobile header')).not.toBeInTheDocument();
|
|
23
|
+
expect(screen.queryByText('Sidebar header')).not.toBeInTheDocument();
|
|
24
|
+
expect(screen.queryByText('Sidebar content')).not.toBeInTheDocument();
|
|
25
|
+
expect(container.querySelector('aside')).not.toBeInTheDocument();
|
|
26
|
+
expect(container.querySelector('nav')).not.toBeInTheDocument();
|
|
27
|
+
expect(container.querySelector('main')).toHaveClass('md:pl-0');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -21,6 +21,7 @@ interface StructureProps {
|
|
|
21
21
|
children: ReactNode;
|
|
22
22
|
onMouseEnter?: () => void;
|
|
23
23
|
onMouseLeave?: () => void;
|
|
24
|
+
sidebarHidden?: boolean;
|
|
24
25
|
sidebarCollapsedWidth?: string;
|
|
25
26
|
sidebarExpandedWidth?: string;
|
|
26
27
|
sidebarHeaderClassName?: string;
|
|
@@ -42,6 +43,7 @@ export function Structure({
|
|
|
42
43
|
children,
|
|
43
44
|
onMouseEnter,
|
|
44
45
|
onMouseLeave,
|
|
46
|
+
sidebarHidden = false,
|
|
45
47
|
sidebarCollapsedWidth = '4rem',
|
|
46
48
|
sidebarExpandedWidth = '16rem',
|
|
47
49
|
sidebarHeaderClassName,
|
|
@@ -60,7 +62,7 @@ export function Structure({
|
|
|
60
62
|
if (typeof window === 'undefined') return;
|
|
61
63
|
|
|
62
64
|
const isMobile = window.innerWidth < 768; // md breakpoint
|
|
63
|
-
if (isMobile && !isCollapsed) {
|
|
65
|
+
if (isMobile && !sidebarHidden && !isCollapsed) {
|
|
64
66
|
const scrollY = window.scrollY;
|
|
65
67
|
document.body.style.position = 'fixed';
|
|
66
68
|
document.body.style.top = `-${scrollY}px`;
|
|
@@ -73,31 +75,35 @@ export function Structure({
|
|
|
73
75
|
window.scrollTo(0, scrollY);
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
|
-
}, [isCollapsed]);
|
|
78
|
+
}, [isCollapsed, sidebarHidden]);
|
|
77
79
|
|
|
78
80
|
return (
|
|
79
81
|
<>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
<div className="
|
|
83
|
-
<div className="flex
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
82
|
+
{!sidebarHidden && (
|
|
83
|
+
<nav className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden">
|
|
84
|
+
<div className="bg-background/50 p-2 font-semibold backdrop-blur-md md:px-8 lg:px-16 xl:px-32">
|
|
85
|
+
<div className="relative flex items-center justify-between gap-2 md:gap-4">
|
|
86
|
+
<div className="flex w-full items-center gap-2">
|
|
87
|
+
{mobileHeader}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex w-fit items-center gap-2">
|
|
90
|
+
{userPopover}
|
|
91
|
+
<Button
|
|
92
|
+
size="icon"
|
|
93
|
+
variant="outline"
|
|
94
|
+
className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
|
|
95
|
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
96
|
+
>
|
|
97
|
+
<Menu className="h-5 w-5" />
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
94
100
|
</div>
|
|
95
101
|
</div>
|
|
96
|
-
</
|
|
97
|
-
|
|
102
|
+
</nav>
|
|
103
|
+
)}
|
|
98
104
|
|
|
99
105
|
<TooltipProvider delayDuration={0}>
|
|
100
|
-
{!isCollapsed && (
|
|
106
|
+
{!sidebarHidden && !isCollapsed && (
|
|
101
107
|
<button
|
|
102
108
|
type="button"
|
|
103
109
|
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-lg md:hidden"
|
|
@@ -105,104 +111,113 @@ export function Structure({
|
|
|
105
111
|
/>
|
|
106
112
|
)}
|
|
107
113
|
<div className="relative w-full">
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
'group fixed top-0 right-0 left-auto z-50 flex h-dvh flex-col overflow-hidden border-l backdrop-blur-lg transition-all duration-300 ease-in-out md:left-0 md:z-20 md:border-r md:border-l-0',
|
|
111
|
-
isCollapsed
|
|
112
|
-
? 'w-[var(--sidebar-collapsed-width)] bg-background/50 max-md:w-0'
|
|
113
|
-
: 'w-[var(--sidebar-expanded-width)] bg-background max-sm:w-full',
|
|
114
|
-
isCollapsed && 'max-md:translate-x-full',
|
|
115
|
-
overlayOnExpand && !isCollapsed && 'md:z-40'
|
|
116
|
-
)}
|
|
117
|
-
onMouseEnter={onMouseEnter}
|
|
118
|
-
onMouseLeave={onMouseLeave}
|
|
119
|
-
style={sidebarStyle}
|
|
120
|
-
>
|
|
121
|
-
<div
|
|
114
|
+
{!sidebarHidden && (
|
|
115
|
+
<aside
|
|
122
116
|
className={cn(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
117
|
+
'group fixed top-0 right-0 left-auto z-50 flex h-dvh flex-col overflow-hidden border-l backdrop-blur-lg transition-all duration-300 ease-in-out md:left-0 md:z-20 md:border-r md:border-l-0',
|
|
118
|
+
isCollapsed
|
|
119
|
+
? 'w-[var(--sidebar-collapsed-width)] bg-background/50 max-md:w-0'
|
|
120
|
+
: 'w-[var(--sidebar-expanded-width)] bg-background max-sm:w-full',
|
|
121
|
+
isCollapsed && 'max-md:translate-x-full',
|
|
122
|
+
overlayOnExpand && !isCollapsed && 'md:z-40'
|
|
128
123
|
)}
|
|
124
|
+
onMouseEnter={onMouseEnter}
|
|
125
|
+
onMouseLeave={onMouseLeave}
|
|
126
|
+
style={sidebarStyle}
|
|
129
127
|
>
|
|
130
128
|
<div
|
|
131
129
|
className={cn(
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
sidebarHeaderHeight
|
|
131
|
+
? 'flex h-[var(--sidebar-header-height)] shrink-0 items-center p-2 md:px-0'
|
|
132
|
+
: 'flex min-h-12 items-center p-2 md:px-0',
|
|
133
|
+
isCollapsed ? 'justify-center' : 'py-1',
|
|
134
|
+
sidebarHeaderClassName
|
|
134
135
|
)}
|
|
135
136
|
>
|
|
136
137
|
<div
|
|
137
138
|
className={cn(
|
|
138
|
-
'flex w-full items-center
|
|
139
|
-
isCollapsed ? 'justify-center' : '
|
|
139
|
+
'flex min-h-13 w-full items-center md:px-2',
|
|
140
|
+
isCollapsed ? 'justify-center' : ''
|
|
140
141
|
)}
|
|
141
142
|
>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
143
|
+
<div
|
|
144
|
+
className={cn(
|
|
145
|
+
'flex w-full items-center gap-2',
|
|
146
|
+
isCollapsed ? 'justify-center' : 'justify-between'
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{sidebarHeader}
|
|
150
|
+
{isCollapsed || (
|
|
151
|
+
<Button
|
|
152
|
+
size="icon"
|
|
153
|
+
variant="outline"
|
|
154
|
+
className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
|
|
155
|
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
156
|
+
>
|
|
157
|
+
<X className="h-5 w-5" />
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
153
161
|
</div>
|
|
154
162
|
</div>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
<div className="scrollbar-none flex flex-1 flex-col gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain">
|
|
164
|
+
{sidebarContent}
|
|
165
|
+
</div>
|
|
166
|
+
{feedbackButton && (
|
|
167
|
+
<div
|
|
168
|
+
className={cn(
|
|
169
|
+
'flex border-foreground/10 border-t p-2',
|
|
170
|
+
isCollapsed ? 'justify-center' : ''
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
{feedbackButton}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
160
176
|
<div
|
|
161
177
|
className={cn(
|
|
162
|
-
'flex border-foreground/10 border-t p-2',
|
|
178
|
+
'mt-auto flex border-foreground/10 border-t p-2',
|
|
163
179
|
isCollapsed ? 'justify-center' : ''
|
|
164
180
|
)}
|
|
165
181
|
>
|
|
166
|
-
{
|
|
182
|
+
{isCollapsed ? userPopover : actions}
|
|
167
183
|
</div>
|
|
168
|
-
)}
|
|
169
|
-
<div
|
|
170
|
-
className={cn(
|
|
171
|
-
'mt-auto flex border-foreground/10 border-t p-2',
|
|
172
|
-
isCollapsed ? 'justify-center' : ''
|
|
173
|
-
)}
|
|
174
|
-
>
|
|
175
|
-
{isCollapsed ? userPopover : actions}
|
|
176
|
-
</div>
|
|
177
184
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
{!hideSizeToggle && (
|
|
186
|
+
<Button
|
|
187
|
+
size="icon"
|
|
188
|
+
variant="outline"
|
|
189
|
+
className="absolute top-1/2 -right-4 z-10 hidden h-auto w-auto -translate-y-1/2 rounded-full border-2 bg-background p-1.5 pl-1.5 opacity-0 transition duration-500 hover:bg-accent group-hover:opacity-100 md:block"
|
|
190
|
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
191
|
+
>
|
|
192
|
+
{isCollapsed ? (
|
|
193
|
+
<ChevronRight className="mr-2 h-4 w-4" />
|
|
194
|
+
) : (
|
|
195
|
+
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
196
|
+
)}
|
|
197
|
+
</Button>
|
|
198
|
+
)}
|
|
199
|
+
</aside>
|
|
200
|
+
)}
|
|
193
201
|
{/* Main content area - overflow-y-auto removed to prevent double scrollbars */}
|
|
194
202
|
{/* Body element now handles page-level scrolling */}
|
|
195
203
|
<main
|
|
196
204
|
className={cn(
|
|
197
205
|
'relative flex h-full min-h-screen flex-col overflow-x-clip overflow-y-visible transition-all duration-300 ease-in-out',
|
|
198
|
-
|
|
199
|
-
? 'md:pl-
|
|
200
|
-
:
|
|
206
|
+
sidebarHidden
|
|
207
|
+
? 'md:pl-0'
|
|
208
|
+
: isCollapsed || overlayOnExpand
|
|
209
|
+
? 'md:pl-[var(--sidebar-collapsed-width)]'
|
|
210
|
+
: 'md:pl-[var(--sidebar-expanded-width)]'
|
|
201
211
|
)}
|
|
202
212
|
style={sidebarStyle}
|
|
203
213
|
>
|
|
204
214
|
{header && <div className="mb-4 hidden md:block">{header}</div>}
|
|
205
|
-
<div
|
|
215
|
+
<div
|
|
216
|
+
className={cn(
|
|
217
|
+
'safe-bottom relative h-full w-full p-2 md:p-4 md:pt-4',
|
|
218
|
+
sidebarHidden ? 'pt-2' : 'pt-17 pl-2'
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
206
221
|
{children}
|
|
207
222
|
</div>
|
|
208
223
|
</main>
|