@hed-hog/core 0.0.298 → 0.0.300
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/dist/dashboard/dashboard/dashboard.controller.d.ts +9 -0
- package/dist/dashboard/dashboard/dashboard.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard/dashboard.service.d.ts +9 -0
- package/dist/dashboard/dashboard/dashboard.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts +14 -1
- package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component/dashboard-component.controller.js +28 -3
- package/dist/dashboard/dashboard-component/dashboard-component.controller.js.map +1 -1
- package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts +22 -1
- package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component/dashboard-component.service.js +185 -35
- package/dist/dashboard/dashboard-component/dashboard-component.service.js.map +1 -1
- package/dist/dashboard/dashboard-component/dto/create.dto.d.ts +1 -0
- package/dist/dashboard/dashboard-component/dto/create.dto.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component/dto/create.dto.js +5 -0
- package/dist/dashboard/dashboard-component/dto/create.dto.js.map +1 -1
- package/dist/dashboard/dashboard-component/dto/update.dto.d.ts +1 -0
- package/dist/dashboard/dashboard-component/dto/update.dto.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component/dto/update.dto.js +5 -0
- package/dist/dashboard/dashboard-component/dto/update.dto.js.map +1 -1
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.controller.d.ts +1 -0
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.service.d.ts +1 -0
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +72 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.js +111 -0
- package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +76 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.js +614 -23
- package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
- package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts +3 -0
- package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts +3 -0
- package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts +2 -0
- package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts +2 -0
- package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts.map +1 -1
- package/hedhog/data/dashboard.yaml +12 -6
- package/hedhog/data/dashboard_component_role.yaml +66 -0
- package/hedhog/data/dashboard_item.yaml +1 -1
- package/hedhog/data/dashboard_role.yaml +2 -8
- package/hedhog/data/route.yaml +84 -0
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +457 -135
- package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +3 -0
- package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +365 -28
- package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +376 -247
- package/hedhog/frontend/app/dashboard/components/draggable-grid.tsx.ejs +64 -18
- package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +1389 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +37 -0
- package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +6 -6
- package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +8 -8
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +3 -3
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +3 -25
- package/hedhog/frontend/messages/en.json +115 -2
- package/hedhog/frontend/messages/pt.json +114 -1
- package/hedhog/frontend/public/dashboard-previews/.gitkeep +12 -0
- package/hedhog/frontend/public/dashboard-previews/account-security.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/active-users-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/activity-timeline.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/cash-balance-kpi.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/cash-flow-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/default-kpi.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/email-notifications.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/financial-alerts.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/login-history-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/mail-sent-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/mail-sent-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/menus-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/payable-30d-kpi.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/permissions-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/permissions-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/profile-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/receivable-30d-kpi.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/routes-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/session-activity-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/sessions-today-card.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/stat-access-level.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/stat-actions-today.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/stat-consecutive-days.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/stat-online-time.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/upcoming-payable.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/upcoming-receivable.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/user-growth-chart.png +0 -0
- package/hedhog/frontend/public/dashboard-previews/user-roles.png +0 -0
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/account-security.tsx.ejs +34 -30
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/active-users-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/activity-timeline.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/email-notifications.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/locale-config.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/login-history-chart.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/mail-config.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/mail-sent-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/mail-sent-chart.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/menus-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/oauth-config.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/permissions-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/permissions-chart.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/profile-card.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/routes-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/session-activity-chart.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/sessions-today-card.tsx.ejs +2 -2
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/stat-access-level.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/stat-actions-today.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/stat-consecutive-days.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/stat-online-time.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/storage-config.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/theme-config.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/user-growth-chart.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/user-roles.tsx.ejs +1 -1
- package/hedhog/frontend/{app/dashboard/components/widgets → widgets}/user-sessions.tsx.ejs +2 -2
- package/hedhog/table/dashboard.yaml +6 -0
- package/hedhog/table/dashboard_component.yaml +7 -0
- package/package.json +5 -5
- package/src/dashboard/dashboard-component/dashboard-component.controller.ts +51 -14
- package/src/dashboard/dashboard-component/dashboard-component.service.ts +254 -43
- package/src/dashboard/dashboard-component/dto/create.dto.ts +4 -0
- package/src/dashboard/dashboard-component/dto/update.dto.ts +4 -0
- package/src/dashboard/dashboard-core/dashboard-core.controller.ts +112 -1
- package/src/dashboard/dashboard-core/dashboard-core.service.ts +782 -24
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Page, PageHeader } from '@/components/entity-list';
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
} from '@/components/ui/alert-dialog';
|
|
14
|
+
import { Badge } from '@/components/ui/badge';
|
|
15
|
+
import { Button } from '@/components/ui/button';
|
|
16
|
+
import {
|
|
17
|
+
ContextMenu,
|
|
18
|
+
ContextMenuContent,
|
|
19
|
+
ContextMenuItem,
|
|
20
|
+
ContextMenuSeparator,
|
|
21
|
+
ContextMenuTrigger,
|
|
22
|
+
} from '@/components/ui/context-menu';
|
|
23
|
+
import { Input } from '@/components/ui/input';
|
|
24
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
25
|
+
import {
|
|
26
|
+
Sheet,
|
|
27
|
+
SheetContent,
|
|
28
|
+
SheetDescription,
|
|
29
|
+
SheetHeader,
|
|
30
|
+
SheetTitle,
|
|
31
|
+
} from '@/components/ui/sheet';
|
|
32
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
33
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
34
|
+
import { cn } from '@/lib/utils';
|
|
35
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
36
|
+
import * as LucideIcons from 'lucide-react';
|
|
37
|
+
import {
|
|
38
|
+
Home,
|
|
39
|
+
LayoutDashboard,
|
|
40
|
+
Loader2,
|
|
41
|
+
Palette,
|
|
42
|
+
Pencil,
|
|
43
|
+
Plus,
|
|
44
|
+
Share2,
|
|
45
|
+
Trash2,
|
|
46
|
+
UserPlus,
|
|
47
|
+
type LucideIcon,
|
|
48
|
+
} from 'lucide-react';
|
|
49
|
+
import { useTranslations } from 'next-intl';
|
|
50
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
51
|
+
import { toast } from 'sonner';
|
|
52
|
+
import { DashboardContent } from './[slug]/dashboard-content';
|
|
53
|
+
|
|
54
|
+
type DashboardTab = {
|
|
55
|
+
id: number;
|
|
56
|
+
slug: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
icon?: string | null;
|
|
59
|
+
is_home?: boolean;
|
|
60
|
+
dashboard_locale?: Array<{
|
|
61
|
+
name?: string | null;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type SharedUser = {
|
|
66
|
+
id: number;
|
|
67
|
+
name: string;
|
|
68
|
+
email: string | null;
|
|
69
|
+
isCurrentUser?: boolean;
|
|
70
|
+
isHome?: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type DashboardTemplate = {
|
|
74
|
+
id: number;
|
|
75
|
+
slug: string;
|
|
76
|
+
name: string;
|
|
77
|
+
icon?: string | null;
|
|
78
|
+
itemCount: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const getDashboardName = (dashboard: DashboardTab) =>
|
|
82
|
+
dashboard.name || dashboard.dashboard_locale?.[0]?.name || dashboard.slug;
|
|
83
|
+
|
|
84
|
+
const DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID = 'dashboard-home-header-actions';
|
|
85
|
+
|
|
86
|
+
const DASHBOARD_ICON_OPTIONS = [
|
|
87
|
+
'layout-dashboard',
|
|
88
|
+
'bar-chart-3',
|
|
89
|
+
'bar-chart-4',
|
|
90
|
+
'chart-column',
|
|
91
|
+
'chart-line',
|
|
92
|
+
'chart-no-axes-combined',
|
|
93
|
+
'pie-chart',
|
|
94
|
+
'trending-up',
|
|
95
|
+
'trending-down',
|
|
96
|
+
'briefcase',
|
|
97
|
+
'building-2',
|
|
98
|
+
'folder-kanban',
|
|
99
|
+
'graduation-cap',
|
|
100
|
+
'book-open',
|
|
101
|
+
'landmark',
|
|
102
|
+
'wallet',
|
|
103
|
+
'badge-dollar-sign',
|
|
104
|
+
'circle-dollar-sign',
|
|
105
|
+
'banknote',
|
|
106
|
+
'hand-coins',
|
|
107
|
+
'credit-card',
|
|
108
|
+
'receipt',
|
|
109
|
+
'shopping-cart',
|
|
110
|
+
'package',
|
|
111
|
+
'store',
|
|
112
|
+
'users',
|
|
113
|
+
'user-round',
|
|
114
|
+
'contact-round',
|
|
115
|
+
'message-square',
|
|
116
|
+
'mail',
|
|
117
|
+
'file-text',
|
|
118
|
+
'file-code-2',
|
|
119
|
+
'circle-help',
|
|
120
|
+
'tags',
|
|
121
|
+
'hash',
|
|
122
|
+
'ticket',
|
|
123
|
+
'shield',
|
|
124
|
+
'settings-2',
|
|
125
|
+
'database',
|
|
126
|
+
'activity',
|
|
127
|
+
'calendar',
|
|
128
|
+
'calendar-days',
|
|
129
|
+
'clock-3',
|
|
130
|
+
'timer',
|
|
131
|
+
'hourglass',
|
|
132
|
+
'globe',
|
|
133
|
+
'target',
|
|
134
|
+
'wrench',
|
|
135
|
+
'home',
|
|
136
|
+
'house',
|
|
137
|
+
'map-pinned',
|
|
138
|
+
'navigation',
|
|
139
|
+
'compass',
|
|
140
|
+
'zap',
|
|
141
|
+
'flame',
|
|
142
|
+
'star',
|
|
143
|
+
'heart',
|
|
144
|
+
'thumbs-up',
|
|
145
|
+
'rocket',
|
|
146
|
+
'sparkles',
|
|
147
|
+
'bell',
|
|
148
|
+
'megaphone',
|
|
149
|
+
'search',
|
|
150
|
+
'filter',
|
|
151
|
+
'list',
|
|
152
|
+
'layout-list',
|
|
153
|
+
'layout-grid',
|
|
154
|
+
'kanban',
|
|
155
|
+
'sliders-horizontal',
|
|
156
|
+
'cpu',
|
|
157
|
+
'monitor',
|
|
158
|
+
'smartphone',
|
|
159
|
+
'tablet',
|
|
160
|
+
'wifi',
|
|
161
|
+
'cloud',
|
|
162
|
+
'cloud-sun',
|
|
163
|
+
'sun',
|
|
164
|
+
'moon',
|
|
165
|
+
'palette',
|
|
166
|
+
'brush-cleaning',
|
|
167
|
+
'camera',
|
|
168
|
+
'image',
|
|
169
|
+
'video',
|
|
170
|
+
'music',
|
|
171
|
+
'headphones',
|
|
172
|
+
'mic',
|
|
173
|
+
'phone',
|
|
174
|
+
'send',
|
|
175
|
+
'inbox',
|
|
176
|
+
'archive',
|
|
177
|
+
'clipboard-list',
|
|
178
|
+
'check-circle-2',
|
|
179
|
+
'x-circle',
|
|
180
|
+
'alert-triangle',
|
|
181
|
+
'info',
|
|
182
|
+
'notebook-text',
|
|
183
|
+
] as const;
|
|
184
|
+
|
|
185
|
+
const normalizeLucideIconName = (value: string) =>
|
|
186
|
+
value
|
|
187
|
+
.trim()
|
|
188
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
189
|
+
.replace(/([a-zA-Z])(\d)/g, '$1-$2')
|
|
190
|
+
.replace(/(\d)([a-zA-Z])/g, '$1-$2')
|
|
191
|
+
.split(/[\s_-]+/)
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.map((part) => part.toLowerCase())
|
|
194
|
+
.join('-');
|
|
195
|
+
|
|
196
|
+
const getLucideComponentName = (value: string) =>
|
|
197
|
+
normalizeLucideIconName(value)
|
|
198
|
+
.split('-')
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
201
|
+
.join('');
|
|
202
|
+
|
|
203
|
+
const isLucideIconComponent = (candidate: unknown): candidate is LucideIcon =>
|
|
204
|
+
candidate !== null &&
|
|
205
|
+
candidate !== undefined &&
|
|
206
|
+
(typeof candidate === 'function' || typeof candidate === 'object');
|
|
207
|
+
|
|
208
|
+
const resolveDashboardIcon = (iconName?: string | null): LucideIcon => {
|
|
209
|
+
const componentName = iconName ? getLucideComponentName(iconName) : '';
|
|
210
|
+
const candidate = componentName
|
|
211
|
+
? LucideIcons[componentName as keyof typeof LucideIcons]
|
|
212
|
+
: undefined;
|
|
213
|
+
|
|
214
|
+
return isLucideIconComponent(candidate)
|
|
215
|
+
? (candidate as LucideIcon)
|
|
216
|
+
: LayoutDashboard;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const isValidLucideIconName = (iconName: string) => {
|
|
220
|
+
const componentName = getLucideComponentName(iconName);
|
|
221
|
+
const candidate = componentName
|
|
222
|
+
? LucideIcons[componentName as keyof typeof LucideIcons]
|
|
223
|
+
: undefined;
|
|
224
|
+
|
|
225
|
+
return isLucideIconComponent(candidate);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getErrorMessage = (error: unknown, fallback: string) => {
|
|
229
|
+
if (
|
|
230
|
+
typeof error === 'object' &&
|
|
231
|
+
error !== null &&
|
|
232
|
+
'response' in error &&
|
|
233
|
+
typeof error.response === 'object' &&
|
|
234
|
+
error.response !== null &&
|
|
235
|
+
'data' in error.response &&
|
|
236
|
+
typeof error.response.data === 'object' &&
|
|
237
|
+
error.response.data !== null &&
|
|
238
|
+
'message' in error.response.data
|
|
239
|
+
) {
|
|
240
|
+
const message = error.response.data.message;
|
|
241
|
+
|
|
242
|
+
if (Array.isArray(message)) {
|
|
243
|
+
return message.join(', ');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof message === 'string') {
|
|
247
|
+
return message;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (error instanceof Error && error.message) {
|
|
252
|
+
return error.message;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return fallback;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export function DashboardHomeTabs() {
|
|
259
|
+
const t = useTranslations('core.DashboardHomeTabs');
|
|
260
|
+
const { request, currentLocaleCode } = useApp();
|
|
261
|
+
const [activeSlug, setActiveSlug] = useState('');
|
|
262
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
263
|
+
const [shareOpen, setShareOpen] = useState(false);
|
|
264
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
265
|
+
const [newDashboardName, setNewDashboardName] = useState('');
|
|
266
|
+
const [newDashboardIcon, setNewDashboardIcon] = useState('layout-dashboard');
|
|
267
|
+
const [selectedTemplateSlug, setSelectedTemplateSlug] = useState('');
|
|
268
|
+
const [shareSearch, setShareSearch] = useState('');
|
|
269
|
+
const [renamingSlug, setRenamingSlug] = useState<string | null>(null);
|
|
270
|
+
const [renameValue, setRenameValue] = useState('');
|
|
271
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
272
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
273
|
+
const [isRenaming, setIsRenaming] = useState(false);
|
|
274
|
+
const [sharingUserId, setSharingUserId] = useState<number | null>(null);
|
|
275
|
+
const [revokingUserId, setRevokingUserId] = useState<number | null>(null);
|
|
276
|
+
const [isSettingHome, setIsSettingHome] = useState(false);
|
|
277
|
+
const [openAddWidgetSignal, setOpenAddWidgetSignal] = useState(0);
|
|
278
|
+
const [iconOpen, setIconOpen] = useState(false);
|
|
279
|
+
const [iconDashboard, setIconDashboard] = useState<DashboardTab | null>(null);
|
|
280
|
+
const [iconValue, setIconValue] = useState('layout-dashboard');
|
|
281
|
+
const [isSavingIcon, setIsSavingIcon] = useState(false);
|
|
282
|
+
|
|
283
|
+
const {
|
|
284
|
+
data: dashboards = [],
|
|
285
|
+
isLoading,
|
|
286
|
+
refetch: refetchDashboards,
|
|
287
|
+
} = useQuery<DashboardTab[]>({
|
|
288
|
+
queryKey: ['dashboard-home-tabs', currentLocaleCode],
|
|
289
|
+
queryFn: async () => {
|
|
290
|
+
const response = await request<DashboardTab[]>({
|
|
291
|
+
url: '/dashboard-core/user-dashboards',
|
|
292
|
+
method: 'GET',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return response.data ?? [];
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const { data: dashboardTemplates = [], isLoading: isLoadingTemplates } =
|
|
300
|
+
useQuery<DashboardTemplate[]>({
|
|
301
|
+
queryKey: ['dashboard-templates', currentLocaleCode, createOpen],
|
|
302
|
+
queryFn: async () => {
|
|
303
|
+
const response = await request<DashboardTemplate[]>({
|
|
304
|
+
url: '/dashboard-core/templates',
|
|
305
|
+
method: 'GET',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return response.data ?? [];
|
|
309
|
+
},
|
|
310
|
+
enabled: createOpen,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const activeDashboard = useMemo(
|
|
314
|
+
() => dashboards.find((dashboard) => dashboard.slug === activeSlug) ?? null,
|
|
315
|
+
[activeSlug, dashboards]
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const selectedTemplate = useMemo(
|
|
319
|
+
() =>
|
|
320
|
+
dashboardTemplates.find(
|
|
321
|
+
(template) => template.slug === selectedTemplateSlug
|
|
322
|
+
) ?? null,
|
|
323
|
+
[dashboardTemplates, selectedTemplateSlug]
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (dashboards.length === 0) {
|
|
328
|
+
setActiveSlug('');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const hasActiveDashboard = dashboards.some(
|
|
333
|
+
(dashboard) => dashboard.slug === activeSlug
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (!activeSlug || !hasActiveDashboard) {
|
|
337
|
+
const nextDashboard =
|
|
338
|
+
dashboards.find((dashboard) => dashboard.is_home) ?? dashboards[0];
|
|
339
|
+
|
|
340
|
+
if (nextDashboard) {
|
|
341
|
+
setActiveSlug(nextDashboard.slug);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}, [activeSlug, dashboards]);
|
|
345
|
+
|
|
346
|
+
const {
|
|
347
|
+
data: sharedUsers = [],
|
|
348
|
+
isLoading: isLoadingShares,
|
|
349
|
+
refetch: refetchShares,
|
|
350
|
+
} = useQuery<SharedUser[]>({
|
|
351
|
+
queryKey: ['dashboard-shares', activeSlug, shareOpen],
|
|
352
|
+
queryFn: async () => {
|
|
353
|
+
if (!activeSlug) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const response = await request<SharedUser[]>({
|
|
358
|
+
url: `/dashboard-core/dashboard/${activeSlug}/shares`,
|
|
359
|
+
method: 'GET',
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return response.data ?? [];
|
|
363
|
+
},
|
|
364
|
+
enabled: shareOpen && Boolean(activeSlug),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const {
|
|
368
|
+
data: shareableUsers = [],
|
|
369
|
+
isLoading: isLoadingShareableUsers,
|
|
370
|
+
refetch: refetchShareableUsers,
|
|
371
|
+
} = useQuery<SharedUser[]>({
|
|
372
|
+
queryKey: ['dashboard-shareable-users', activeSlug, shareSearch, shareOpen],
|
|
373
|
+
queryFn: async () => {
|
|
374
|
+
if (!activeSlug) {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const params = new URLSearchParams();
|
|
379
|
+
if (shareSearch.trim()) {
|
|
380
|
+
params.set('search', shareSearch.trim());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const queryString = params.toString();
|
|
384
|
+
const response = await request<SharedUser[]>({
|
|
385
|
+
url: `/dashboard-core/shareable-users/${activeSlug}${
|
|
386
|
+
queryString ? `?${queryString}` : ''
|
|
387
|
+
}`,
|
|
388
|
+
method: 'GET',
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return response.data ?? [];
|
|
392
|
+
},
|
|
393
|
+
enabled: shareOpen && Boolean(activeSlug),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const handleSelectTemplate = (template: DashboardTemplate | null) => {
|
|
397
|
+
setSelectedTemplateSlug(template?.slug ?? '');
|
|
398
|
+
setNewDashboardName(template?.name ?? '');
|
|
399
|
+
setNewDashboardIcon(template?.icon ?? 'layout-dashboard');
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const handleCreateDashboard = async () => {
|
|
403
|
+
const trimmedName = newDashboardName.trim();
|
|
404
|
+
const normalizedIcon = newDashboardIcon.trim()
|
|
405
|
+
? normalizeLucideIconName(newDashboardIcon)
|
|
406
|
+
: 'layout-dashboard';
|
|
407
|
+
|
|
408
|
+
if (!trimmedName) {
|
|
409
|
+
toast.error(t('enterDashboardName'));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (normalizedIcon && !isValidLucideIconName(normalizedIcon)) {
|
|
414
|
+
toast.error(t('enterValidLucideIcon'));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
setIsCreating(true);
|
|
420
|
+
|
|
421
|
+
const response = await request<DashboardTab>({
|
|
422
|
+
url: '/dashboard-core/dashboard',
|
|
423
|
+
method: 'POST',
|
|
424
|
+
data: {
|
|
425
|
+
name: trimmedName,
|
|
426
|
+
icon: normalizedIcon,
|
|
427
|
+
...(selectedTemplateSlug
|
|
428
|
+
? { templateSlug: selectedTemplateSlug }
|
|
429
|
+
: {}),
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await refetchDashboards();
|
|
434
|
+
setActiveSlug(response.data?.slug ?? activeSlug);
|
|
435
|
+
setNewDashboardName('');
|
|
436
|
+
setNewDashboardIcon('layout-dashboard');
|
|
437
|
+
setSelectedTemplateSlug('');
|
|
438
|
+
setCreateOpen(false);
|
|
439
|
+
toast.success(t('dashboardCreated'));
|
|
440
|
+
} catch (error) {
|
|
441
|
+
toast.error(getErrorMessage(error, t('createDashboardError')));
|
|
442
|
+
} finally {
|
|
443
|
+
setIsCreating(false);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const handleSetHomeDashboard = async (dashboard = activeDashboard) => {
|
|
448
|
+
if (!dashboard || dashboard.is_home) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
cancelRenameDashboard();
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
setIsSettingHome(true);
|
|
456
|
+
await request({
|
|
457
|
+
url: `/dashboard-core/dashboard/${dashboard.slug}/home`,
|
|
458
|
+
method: 'POST',
|
|
459
|
+
});
|
|
460
|
+
await refetchDashboards();
|
|
461
|
+
toast.success(t('setAsHomeSuccess'));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
toast.error(getErrorMessage(error, t('updateHomeError')));
|
|
464
|
+
} finally {
|
|
465
|
+
setIsSettingHome(false);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const startRenameDashboard = (dashboard: DashboardTab) => {
|
|
470
|
+
setActiveSlug(dashboard.slug);
|
|
471
|
+
setRenamingSlug(dashboard.slug);
|
|
472
|
+
setRenameValue(getDashboardName(dashboard));
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const cancelRenameDashboard = () => {
|
|
476
|
+
setRenamingSlug(null);
|
|
477
|
+
setRenameValue('');
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const handleRenameDashboard = async (dashboard: DashboardTab) => {
|
|
481
|
+
const trimmedName = renameValue.trim();
|
|
482
|
+
|
|
483
|
+
if (!trimmedName) {
|
|
484
|
+
toast.error(t('enterDashboardName'));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (trimmedName === getDashboardName(dashboard)) {
|
|
489
|
+
cancelRenameDashboard();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
setIsRenaming(true);
|
|
495
|
+
await request({
|
|
496
|
+
url: `/dashboard-core/dashboard/${dashboard.slug}`,
|
|
497
|
+
method: 'PATCH',
|
|
498
|
+
data: { name: trimmedName },
|
|
499
|
+
});
|
|
500
|
+
await refetchDashboards();
|
|
501
|
+
cancelRenameDashboard();
|
|
502
|
+
toast.success(t('dashboardRenamed'));
|
|
503
|
+
} catch (error) {
|
|
504
|
+
toast.error(getErrorMessage(error, t('renameDashboardError')));
|
|
505
|
+
} finally {
|
|
506
|
+
setIsRenaming(false);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const openShareForDashboard = (dashboard: DashboardTab) => {
|
|
511
|
+
cancelRenameDashboard();
|
|
512
|
+
setActiveSlug(dashboard.slug);
|
|
513
|
+
setShareOpen(true);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const openDeleteForDashboard = (dashboard: DashboardTab) => {
|
|
517
|
+
cancelRenameDashboard();
|
|
518
|
+
setActiveSlug(dashboard.slug);
|
|
519
|
+
setDeleteOpen(true);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const openAddWidgetsForDashboard = (dashboard: DashboardTab) => {
|
|
523
|
+
cancelRenameDashboard();
|
|
524
|
+
setActiveSlug(dashboard.slug);
|
|
525
|
+
setOpenAddWidgetSignal((previous) => previous + 1);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const openIconForDashboard = (dashboard: DashboardTab) => {
|
|
529
|
+
cancelRenameDashboard();
|
|
530
|
+
setActiveSlug(dashboard.slug);
|
|
531
|
+
setIconDashboard(dashboard);
|
|
532
|
+
setIconValue(
|
|
533
|
+
dashboard.icon
|
|
534
|
+
? normalizeLucideIconName(dashboard.icon)
|
|
535
|
+
: 'layout-dashboard'
|
|
536
|
+
);
|
|
537
|
+
setIconOpen(true);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const handleSaveDashboardIcon = async () => {
|
|
541
|
+
if (!iconDashboard) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const trimmedValue = iconValue.trim();
|
|
546
|
+
const normalizedIcon = trimmedValue
|
|
547
|
+
? normalizeLucideIconName(trimmedValue)
|
|
548
|
+
: null;
|
|
549
|
+
|
|
550
|
+
if (normalizedIcon && !isValidLucideIconName(normalizedIcon)) {
|
|
551
|
+
toast.error(t('enterValidLucideIcon'));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
setIsSavingIcon(true);
|
|
557
|
+
await request({
|
|
558
|
+
url: `/dashboard-core/dashboard/${iconDashboard.slug}`,
|
|
559
|
+
method: 'PATCH',
|
|
560
|
+
data: {
|
|
561
|
+
icon: normalizedIcon,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
await refetchDashboards();
|
|
565
|
+
setIconOpen(false);
|
|
566
|
+
setIconDashboard(null);
|
|
567
|
+
toast.success(t('dashboardIconUpdated'));
|
|
568
|
+
} catch (error) {
|
|
569
|
+
toast.error(getErrorMessage(error, t('updateDashboardIconError')));
|
|
570
|
+
} finally {
|
|
571
|
+
setIsSavingIcon(false);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const handleRemoveDashboard = async () => {
|
|
576
|
+
if (!activeDashboard) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
setIsDeleting(true);
|
|
582
|
+
await request({
|
|
583
|
+
url: `/dashboard-core/dashboard/${activeDashboard.slug}`,
|
|
584
|
+
method: 'DELETE',
|
|
585
|
+
});
|
|
586
|
+
await refetchDashboards();
|
|
587
|
+
setDeleteOpen(false);
|
|
588
|
+
toast.success(t('tabRemoved'));
|
|
589
|
+
} catch (error) {
|
|
590
|
+
toast.error(getErrorMessage(error, t('removeTabError')));
|
|
591
|
+
} finally {
|
|
592
|
+
setIsDeleting(false);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const handleShareWithUser = async (userId: number) => {
|
|
597
|
+
if (!activeDashboard) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
setSharingUserId(userId);
|
|
603
|
+
await request({
|
|
604
|
+
url: `/dashboard-core/dashboard/${activeDashboard.slug}/share`,
|
|
605
|
+
method: 'POST',
|
|
606
|
+
data: { userId },
|
|
607
|
+
});
|
|
608
|
+
await Promise.all([refetchShares(), refetchShareableUsers()]);
|
|
609
|
+
toast.success(t('dashboardShared'));
|
|
610
|
+
} catch (error) {
|
|
611
|
+
toast.error(getErrorMessage(error, t('shareDashboardError')));
|
|
612
|
+
} finally {
|
|
613
|
+
setSharingUserId(null);
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const handleRevokeShare = async (userId: number) => {
|
|
618
|
+
if (!activeDashboard) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
setRevokingUserId(userId);
|
|
624
|
+
await request({
|
|
625
|
+
url: `/dashboard-core/dashboard/${activeDashboard.slug}/share/${userId}`,
|
|
626
|
+
method: 'DELETE',
|
|
627
|
+
});
|
|
628
|
+
await Promise.all([refetchShares(), refetchShareableUsers()]);
|
|
629
|
+
toast.success(t('shareRemoved'));
|
|
630
|
+
} catch (error) {
|
|
631
|
+
toast.error(getErrorMessage(error, t('revokeShareError')));
|
|
632
|
+
} finally {
|
|
633
|
+
setRevokingUserId(null);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const isBusy =
|
|
638
|
+
isCreating ||
|
|
639
|
+
isDeleting ||
|
|
640
|
+
isRenaming ||
|
|
641
|
+
isSettingHome ||
|
|
642
|
+
isSavingIcon ||
|
|
643
|
+
sharingUserId !== null ||
|
|
644
|
+
revokingUserId !== null;
|
|
645
|
+
|
|
646
|
+
const headerActions = (
|
|
647
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
648
|
+
{activeDashboard ? (
|
|
649
|
+
<div
|
|
650
|
+
id={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
|
|
651
|
+
className="contents"
|
|
652
|
+
/>
|
|
653
|
+
) : null}
|
|
654
|
+
<Button
|
|
655
|
+
variant="outline"
|
|
656
|
+
size="sm"
|
|
657
|
+
onClick={() =>
|
|
658
|
+
activeDashboard && openShareForDashboard(activeDashboard)
|
|
659
|
+
}
|
|
660
|
+
disabled={!activeDashboard || isBusy}
|
|
661
|
+
>
|
|
662
|
+
<Share2 className="size-4" />
|
|
663
|
+
{t('share')}
|
|
664
|
+
</Button>
|
|
665
|
+
<Button size="sm" onClick={() => setCreateOpen(true)} disabled={isBusy}>
|
|
666
|
+
<Plus className="size-4" />
|
|
667
|
+
{t('newDashboard')}
|
|
668
|
+
</Button>
|
|
669
|
+
</div>
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
return (
|
|
673
|
+
<Page>
|
|
674
|
+
<PageHeader
|
|
675
|
+
breadcrumbs={[
|
|
676
|
+
{ label: t('pageTitle'), href: '/core/dashboard' },
|
|
677
|
+
...(activeDashboard
|
|
678
|
+
? [{ label: getDashboardName(activeDashboard) }]
|
|
679
|
+
: [{ label: t('home') }]),
|
|
680
|
+
]}
|
|
681
|
+
title={t('pageTitle')}
|
|
682
|
+
description={t('pageDescription')}
|
|
683
|
+
actions={headerActions}
|
|
684
|
+
/>
|
|
685
|
+
|
|
686
|
+
{isLoading ? (
|
|
687
|
+
<div className="flex gap-2">
|
|
688
|
+
<Skeleton className="h-10 w-32 rounded-lg" />
|
|
689
|
+
<Skeleton className="h-10 w-36 rounded-lg" />
|
|
690
|
+
<Skeleton className="h-10 w-28 rounded-lg" />
|
|
691
|
+
</div>
|
|
692
|
+
) : dashboards.length > 0 ? (
|
|
693
|
+
<div className="overflow-hidden">
|
|
694
|
+
<Tabs
|
|
695
|
+
value={activeSlug}
|
|
696
|
+
onValueChange={setActiveSlug}
|
|
697
|
+
className="gap-0"
|
|
698
|
+
>
|
|
699
|
+
<div className="bg-muted dark:bg-muted/40">
|
|
700
|
+
<ScrollArea className="w-full whitespace-nowrap">
|
|
701
|
+
<TabsList className="dashboard-tabs-list h-auto min-h-0 w-max items-end gap-0 rounded-none bg-transparent p-0 pt-1 pl-1">
|
|
702
|
+
{dashboards.map((dashboard) => {
|
|
703
|
+
const isRenamingCurrentTab =
|
|
704
|
+
renamingSlug === dashboard.slug;
|
|
705
|
+
const isActiveTab = activeSlug === dashboard.slug;
|
|
706
|
+
const DashboardTabIcon = resolveDashboardIcon(
|
|
707
|
+
dashboard.icon
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
const tabContent = (
|
|
711
|
+
<>
|
|
712
|
+
<DashboardTabIcon
|
|
713
|
+
className={cn('size-4 transition-colors')}
|
|
714
|
+
/>
|
|
715
|
+
{isRenamingCurrentTab ? (
|
|
716
|
+
<Input
|
|
717
|
+
value={renameValue}
|
|
718
|
+
onChange={(event) =>
|
|
719
|
+
setRenameValue(event.target.value)
|
|
720
|
+
}
|
|
721
|
+
onClick={(event) => event.stopPropagation()}
|
|
722
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
723
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
724
|
+
onBlur={() => void handleRenameDashboard(dashboard)}
|
|
725
|
+
onKeyDown={(event) => {
|
|
726
|
+
if (event.key === 'Enter') {
|
|
727
|
+
event.preventDefault();
|
|
728
|
+
void handleRenameDashboard(dashboard);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (event.key === 'Escape') {
|
|
732
|
+
event.preventDefault();
|
|
733
|
+
cancelRenameDashboard();
|
|
734
|
+
}
|
|
735
|
+
}}
|
|
736
|
+
autoFocus
|
|
737
|
+
disabled={isRenaming}
|
|
738
|
+
className="h-7 w-40"
|
|
739
|
+
/>
|
|
740
|
+
) : (
|
|
741
|
+
<span className="max-w-45 truncate">
|
|
742
|
+
{getDashboardName(dashboard)}
|
|
743
|
+
</span>
|
|
744
|
+
)}
|
|
745
|
+
</>
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<ContextMenu key={dashboard.slug}>
|
|
750
|
+
<ContextMenuTrigger asChild>
|
|
751
|
+
{isRenamingCurrentTab ? (
|
|
752
|
+
<div className="-mb-px flex min-h-9 items-center gap-2 rounded-b-none rounded-t-lg border border-b-0 border-border bg-background px-3 py-2 text-foreground shadow-none">
|
|
753
|
+
{tabContent}
|
|
754
|
+
</div>
|
|
755
|
+
) : (
|
|
756
|
+
<TabsTrigger
|
|
757
|
+
value={dashboard.slug}
|
|
758
|
+
className="dashboard-tab-trigger -mb-px cursor-pointer gap-2 rounded-b-none rounded-t-lg px-3 py-2 mt-1 border-none shadow-none data-[state=active]:shadow-none"
|
|
759
|
+
style={
|
|
760
|
+
isActiveTab
|
|
761
|
+
? {
|
|
762
|
+
backgroundColor: 'var(--background)',
|
|
763
|
+
color: 'var(--primary)',
|
|
764
|
+
}
|
|
765
|
+
: {
|
|
766
|
+
backgroundColor: 'transparent',
|
|
767
|
+
color: 'var(--muted-foreground)',
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
onDoubleClick={() =>
|
|
771
|
+
startRenameDashboard(dashboard)
|
|
772
|
+
}
|
|
773
|
+
onContextMenu={() =>
|
|
774
|
+
setActiveSlug(dashboard.slug)
|
|
775
|
+
}
|
|
776
|
+
>
|
|
777
|
+
{tabContent}
|
|
778
|
+
</TabsTrigger>
|
|
779
|
+
)}
|
|
780
|
+
</ContextMenuTrigger>
|
|
781
|
+
<ContextMenuContent className="w-56">
|
|
782
|
+
<ContextMenuItem
|
|
783
|
+
onSelect={() =>
|
|
784
|
+
openAddWidgetsForDashboard(dashboard)
|
|
785
|
+
}
|
|
786
|
+
>
|
|
787
|
+
<Plus className="size-4" />
|
|
788
|
+
{t('addWidgets')}
|
|
789
|
+
</ContextMenuItem>
|
|
790
|
+
{!dashboard.is_home ? (
|
|
791
|
+
<ContextMenuItem
|
|
792
|
+
onSelect={() =>
|
|
793
|
+
void handleSetHomeDashboard(dashboard)
|
|
794
|
+
}
|
|
795
|
+
>
|
|
796
|
+
<Home className="size-4" />
|
|
797
|
+
{t('setAsHome')}
|
|
798
|
+
</ContextMenuItem>
|
|
799
|
+
) : null}
|
|
800
|
+
<ContextMenuItem
|
|
801
|
+
onSelect={() => startRenameDashboard(dashboard)}
|
|
802
|
+
>
|
|
803
|
+
<Pencil className="size-4" />
|
|
804
|
+
{t('rename')}
|
|
805
|
+
</ContextMenuItem>
|
|
806
|
+
<ContextMenuItem
|
|
807
|
+
onSelect={() => openIconForDashboard(dashboard)}
|
|
808
|
+
>
|
|
809
|
+
<Palette className="size-4" />
|
|
810
|
+
{t('changeIcon')}
|
|
811
|
+
</ContextMenuItem>
|
|
812
|
+
<ContextMenuItem
|
|
813
|
+
onSelect={() => openShareForDashboard(dashboard)}
|
|
814
|
+
>
|
|
815
|
+
<Share2 className="size-4" />
|
|
816
|
+
{t('share')}
|
|
817
|
+
</ContextMenuItem>
|
|
818
|
+
<ContextMenuSeparator />
|
|
819
|
+
<ContextMenuItem
|
|
820
|
+
variant="destructive"
|
|
821
|
+
onSelect={() => openDeleteForDashboard(dashboard)}
|
|
822
|
+
>
|
|
823
|
+
<Trash2 className="size-4" />
|
|
824
|
+
{t('delete')}
|
|
825
|
+
</ContextMenuItem>
|
|
826
|
+
</ContextMenuContent>
|
|
827
|
+
</ContextMenu>
|
|
828
|
+
);
|
|
829
|
+
})}
|
|
830
|
+
<button
|
|
831
|
+
type="button"
|
|
832
|
+
onClick={() => setCreateOpen(true)}
|
|
833
|
+
disabled={isBusy}
|
|
834
|
+
className="my-auto ml-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
835
|
+
title={t('newDashboard')}
|
|
836
|
+
>
|
|
837
|
+
<Plus className="size-4" />
|
|
838
|
+
</button>
|
|
839
|
+
</TabsList>
|
|
840
|
+
</ScrollArea>
|
|
841
|
+
</div>
|
|
842
|
+
</Tabs>
|
|
843
|
+
</div>
|
|
844
|
+
) : (
|
|
845
|
+
<div className="rounded-lg border border-dashed p-6 text-center">
|
|
846
|
+
<p className="text-sm font-medium">{t('emptyTitle')}</p>
|
|
847
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
848
|
+
{t('emptyDescription')}
|
|
849
|
+
</p>
|
|
850
|
+
</div>
|
|
851
|
+
)}
|
|
852
|
+
|
|
853
|
+
{activeDashboard ? (
|
|
854
|
+
<DashboardContent
|
|
855
|
+
key={activeDashboard.slug}
|
|
856
|
+
dashboardSlug={activeDashboard.slug}
|
|
857
|
+
showHeader={false}
|
|
858
|
+
headerActionsTargetId={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
|
|
859
|
+
openWidgetPickerSignal={openAddWidgetSignal}
|
|
860
|
+
onOpenWidgetPickerHandled={() => setOpenAddWidgetSignal(0)}
|
|
861
|
+
/>
|
|
862
|
+
) : !isLoading ? (
|
|
863
|
+
<div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
864
|
+
{t('createToStart')}
|
|
865
|
+
</div>
|
|
866
|
+
) : null}
|
|
867
|
+
|
|
868
|
+
<Sheet
|
|
869
|
+
open={createOpen}
|
|
870
|
+
onOpenChange={(open) => {
|
|
871
|
+
setCreateOpen(open);
|
|
872
|
+
if (!open) {
|
|
873
|
+
setNewDashboardName('');
|
|
874
|
+
setNewDashboardIcon('layout-dashboard');
|
|
875
|
+
setSelectedTemplateSlug('');
|
|
876
|
+
}
|
|
877
|
+
}}
|
|
878
|
+
>
|
|
879
|
+
<SheetContent side="right" className="w-full sm:max-w-xl">
|
|
880
|
+
<div className="flex h-full flex-col">
|
|
881
|
+
<SheetHeader className="px-6 py-5 text-left">
|
|
882
|
+
<SheetTitle>{t('newDashboardTitle')}</SheetTitle>
|
|
883
|
+
<SheetDescription>
|
|
884
|
+
{t('newDashboardDescription')}
|
|
885
|
+
</SheetDescription>
|
|
886
|
+
</SheetHeader>
|
|
887
|
+
|
|
888
|
+
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-4">
|
|
889
|
+
<div className="space-y-2">
|
|
890
|
+
<div className="flex items-center justify-between gap-2">
|
|
891
|
+
<label className="text-sm font-medium">
|
|
892
|
+
{t('dashboardTemplateLabel')}
|
|
893
|
+
</label>
|
|
894
|
+
<Badge variant={selectedTemplate ? 'secondary' : 'outline'}>
|
|
895
|
+
{selectedTemplate
|
|
896
|
+
? t('templateSelectedBadge')
|
|
897
|
+
: t('blankDashboardBadge')}
|
|
898
|
+
</Badge>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
<div className="grid gap-2">
|
|
902
|
+
<button
|
|
903
|
+
type="button"
|
|
904
|
+
className={cn(
|
|
905
|
+
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/30',
|
|
906
|
+
!selectedTemplateSlug &&
|
|
907
|
+
'border-primary bg-primary/5 text-foreground'
|
|
908
|
+
)}
|
|
909
|
+
onClick={() => handleSelectTemplate(null)}
|
|
910
|
+
>
|
|
911
|
+
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
|
912
|
+
<LayoutDashboard className="size-4" />
|
|
913
|
+
</div>
|
|
914
|
+
<div className="min-w-0">
|
|
915
|
+
<p className="text-sm font-medium">
|
|
916
|
+
{t('blankDashboardTitle')}
|
|
917
|
+
</p>
|
|
918
|
+
<p className="text-muted-foreground text-xs">
|
|
919
|
+
{t('blankDashboardDescription')}
|
|
920
|
+
</p>
|
|
921
|
+
</div>
|
|
922
|
+
</button>
|
|
923
|
+
|
|
924
|
+
{isLoadingTemplates ? (
|
|
925
|
+
<div className="grid gap-2">
|
|
926
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
927
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
928
|
+
</div>
|
|
929
|
+
) : dashboardTemplates.length > 0 ? (
|
|
930
|
+
dashboardTemplates.map((template) => {
|
|
931
|
+
const TemplateIcon = resolveDashboardIcon(template.icon);
|
|
932
|
+
const isSelected = template.slug === selectedTemplateSlug;
|
|
933
|
+
|
|
934
|
+
return (
|
|
935
|
+
<button
|
|
936
|
+
key={template.slug}
|
|
937
|
+
type="button"
|
|
938
|
+
className={cn(
|
|
939
|
+
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/30',
|
|
940
|
+
isSelected &&
|
|
941
|
+
'border-primary bg-primary/5 text-foreground'
|
|
942
|
+
)}
|
|
943
|
+
onClick={() => handleSelectTemplate(template)}
|
|
944
|
+
>
|
|
945
|
+
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
|
946
|
+
<TemplateIcon className="size-4" />
|
|
947
|
+
</div>
|
|
948
|
+
<div className="min-w-0 flex-1">
|
|
949
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
950
|
+
<p className="text-sm font-medium">
|
|
951
|
+
{template.name}
|
|
952
|
+
</p>
|
|
953
|
+
<Badge variant="outline">
|
|
954
|
+
{t('templateWidgetCount', {
|
|
955
|
+
count: template.itemCount,
|
|
956
|
+
})}
|
|
957
|
+
</Badge>
|
|
958
|
+
</div>
|
|
959
|
+
<p className="text-muted-foreground text-xs">
|
|
960
|
+
{template.slug}
|
|
961
|
+
</p>
|
|
962
|
+
</div>
|
|
963
|
+
</button>
|
|
964
|
+
);
|
|
965
|
+
})
|
|
966
|
+
) : (
|
|
967
|
+
<p className="text-muted-foreground text-xs">
|
|
968
|
+
{t('noTemplatesAvailable')}
|
|
969
|
+
</p>
|
|
970
|
+
)}
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
|
|
974
|
+
<div className="space-y-2">
|
|
975
|
+
<label className="text-sm font-medium" htmlFor="dashboard-name">
|
|
976
|
+
{t('dashboardNameLabel')}
|
|
977
|
+
</label>
|
|
978
|
+
<Input
|
|
979
|
+
id="dashboard-name"
|
|
980
|
+
value={newDashboardName}
|
|
981
|
+
onChange={(event) => setNewDashboardName(event.target.value)}
|
|
982
|
+
placeholder={t('dashboardNamePlaceholder')}
|
|
983
|
+
onKeyDown={(event) => {
|
|
984
|
+
if (event.key === 'Enter') {
|
|
985
|
+
event.preventDefault();
|
|
986
|
+
void handleCreateDashboard();
|
|
987
|
+
}
|
|
988
|
+
}}
|
|
989
|
+
/>
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
<div className="space-y-2">
|
|
993
|
+
<label
|
|
994
|
+
className="text-sm font-medium"
|
|
995
|
+
htmlFor="new-dashboard-icon-name"
|
|
996
|
+
>
|
|
997
|
+
{t('dashboardIconLabel')}
|
|
998
|
+
</label>
|
|
999
|
+
<Input
|
|
1000
|
+
id="new-dashboard-icon-name"
|
|
1001
|
+
value={newDashboardIcon}
|
|
1002
|
+
onChange={(event) => setNewDashboardIcon(event.target.value)}
|
|
1003
|
+
placeholder={t('dashboardIconPlaceholder')}
|
|
1004
|
+
/>
|
|
1005
|
+
<p className="text-muted-foreground text-xs">
|
|
1006
|
+
{newDashboardIcon.trim() &&
|
|
1007
|
+
!isValidLucideIconName(newDashboardIcon)
|
|
1008
|
+
? t('invalidIconName')
|
|
1009
|
+
: t('selectIconHint')}
|
|
1010
|
+
</p>
|
|
1011
|
+
</div>
|
|
1012
|
+
|
|
1013
|
+
<div className="rounded-lg border p-4">
|
|
1014
|
+
<div className="flex items-center gap-3">
|
|
1015
|
+
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
|
|
1016
|
+
{(() => {
|
|
1017
|
+
const SelectedIcon =
|
|
1018
|
+
resolveDashboardIcon(newDashboardIcon);
|
|
1019
|
+
return <SelectedIcon className="size-5" />;
|
|
1020
|
+
})()}
|
|
1021
|
+
</div>
|
|
1022
|
+
<div className="min-w-0">
|
|
1023
|
+
<p className="truncate text-sm font-medium">
|
|
1024
|
+
{newDashboardIcon.trim()
|
|
1025
|
+
? normalizeLucideIconName(newDashboardIcon)
|
|
1026
|
+
: 'layout-dashboard'}
|
|
1027
|
+
</p>
|
|
1028
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
1029
|
+
{newDashboardName.trim() || t('newDashboard')}
|
|
1030
|
+
</p>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
|
|
1035
|
+
<div className="space-y-2">
|
|
1036
|
+
<p className="text-sm font-medium">{t('iconSuggestions')}</p>
|
|
1037
|
+
<div className="grid gap-2 grid-cols-12">
|
|
1038
|
+
{DASHBOARD_ICON_OPTIONS.map((iconName) => {
|
|
1039
|
+
const IconOption = resolveDashboardIcon(iconName);
|
|
1040
|
+
const isSelected =
|
|
1041
|
+
normalizeLucideIconName(newDashboardIcon) === iconName;
|
|
1042
|
+
|
|
1043
|
+
return (
|
|
1044
|
+
<button
|
|
1045
|
+
key={`create-${iconName}`}
|
|
1046
|
+
type="button"
|
|
1047
|
+
title={iconName}
|
|
1048
|
+
aria-label={iconName}
|
|
1049
|
+
className={cn(
|
|
1050
|
+
'flex aspect-square cursor-pointer items-center justify-center rounded-lg border p-2 text-sm transition-colors hover:border-primary/40 hover:bg-accent/30',
|
|
1051
|
+
isSelected &&
|
|
1052
|
+
'border-primary bg-primary/10 text-primary'
|
|
1053
|
+
)}
|
|
1054
|
+
onClick={() => setNewDashboardIcon(iconName)}
|
|
1055
|
+
>
|
|
1056
|
+
<IconOption className="size-5" />
|
|
1057
|
+
</button>
|
|
1058
|
+
);
|
|
1059
|
+
})}
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
|
|
1064
|
+
<div className="mt-auto flex justify-end gap-2 border-t px-6 py-4">
|
|
1065
|
+
<Button
|
|
1066
|
+
className="w-full"
|
|
1067
|
+
onClick={handleCreateDashboard}
|
|
1068
|
+
disabled={isCreating}
|
|
1069
|
+
>
|
|
1070
|
+
{isCreating ? (
|
|
1071
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1072
|
+
) : (
|
|
1073
|
+
<Plus className="size-4" />
|
|
1074
|
+
)}
|
|
1075
|
+
{t('createDashboard')}
|
|
1076
|
+
</Button>
|
|
1077
|
+
</div>
|
|
1078
|
+
</div>
|
|
1079
|
+
</SheetContent>
|
|
1080
|
+
</Sheet>
|
|
1081
|
+
|
|
1082
|
+
<Sheet
|
|
1083
|
+
open={iconOpen}
|
|
1084
|
+
onOpenChange={(open) => {
|
|
1085
|
+
setIconOpen(open);
|
|
1086
|
+
if (!open) {
|
|
1087
|
+
setIconDashboard(null);
|
|
1088
|
+
setIconValue('layout-dashboard');
|
|
1089
|
+
}
|
|
1090
|
+
}}
|
|
1091
|
+
>
|
|
1092
|
+
<SheetContent side="right" className="w-full sm:max-w-xl">
|
|
1093
|
+
<div className="flex h-full flex-col">
|
|
1094
|
+
<SheetHeader className="px-6 py-5 text-left">
|
|
1095
|
+
<SheetTitle>{t('changeDashboardIconTitle')}</SheetTitle>
|
|
1096
|
+
<SheetDescription>
|
|
1097
|
+
{t('changeDashboardIconDescription')}
|
|
1098
|
+
</SheetDescription>
|
|
1099
|
+
</SheetHeader>
|
|
1100
|
+
|
|
1101
|
+
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-4">
|
|
1102
|
+
<div className="space-y-2">
|
|
1103
|
+
<label
|
|
1104
|
+
className="text-sm font-medium"
|
|
1105
|
+
htmlFor="dashboard-icon-name"
|
|
1106
|
+
>
|
|
1107
|
+
{t('iconNameLabel')}
|
|
1108
|
+
</label>
|
|
1109
|
+
<Input
|
|
1110
|
+
id="dashboard-icon-name"
|
|
1111
|
+
value={iconValue}
|
|
1112
|
+
onChange={(event) => setIconValue(event.target.value)}
|
|
1113
|
+
placeholder={t('iconNamePlaceholder')}
|
|
1114
|
+
/>
|
|
1115
|
+
<p className="text-muted-foreground text-xs">
|
|
1116
|
+
{iconValue.trim() && !isValidLucideIconName(iconValue)
|
|
1117
|
+
? t('invalidIconName')
|
|
1118
|
+
: t('selectIconHint')}
|
|
1119
|
+
</p>
|
|
1120
|
+
</div>
|
|
1121
|
+
|
|
1122
|
+
<div className="rounded-lg border p-4">
|
|
1123
|
+
<div className="flex items-center gap-3">
|
|
1124
|
+
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
|
|
1125
|
+
{(() => {
|
|
1126
|
+
const SelectedIcon = resolveDashboardIcon(iconValue);
|
|
1127
|
+
return <SelectedIcon className="size-5" />;
|
|
1128
|
+
})()}
|
|
1129
|
+
</div>
|
|
1130
|
+
<div className="min-w-0">
|
|
1131
|
+
<p className="truncate text-sm font-medium">
|
|
1132
|
+
{iconValue.trim()
|
|
1133
|
+
? normalizeLucideIconName(iconValue)
|
|
1134
|
+
: t('noCustomIcon')}
|
|
1135
|
+
</p>
|
|
1136
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
1137
|
+
{iconDashboard
|
|
1138
|
+
? getDashboardName(iconDashboard)
|
|
1139
|
+
: t('dashboardLabel')}
|
|
1140
|
+
</p>
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
</div>
|
|
1144
|
+
|
|
1145
|
+
<div className="space-y-2">
|
|
1146
|
+
<p className="text-sm font-medium">{t('suggestions')}</p>
|
|
1147
|
+
<div className="grid gap-2 grid-cols-12">
|
|
1148
|
+
{DASHBOARD_ICON_OPTIONS.map((iconName) => {
|
|
1149
|
+
const IconOption = resolveDashboardIcon(iconName);
|
|
1150
|
+
const isSelected =
|
|
1151
|
+
normalizeLucideIconName(iconValue) === iconName;
|
|
1152
|
+
|
|
1153
|
+
return (
|
|
1154
|
+
<button
|
|
1155
|
+
key={iconName}
|
|
1156
|
+
type="button"
|
|
1157
|
+
title={iconName}
|
|
1158
|
+
aria-label={iconName}
|
|
1159
|
+
className={cn(
|
|
1160
|
+
'flex aspect-square cursor-pointer items-center justify-center rounded-lg border p-2 text-sm transition-colors hover:border-primary/40 hover:bg-accent/30',
|
|
1161
|
+
isSelected &&
|
|
1162
|
+
'border-primary bg-primary/10 text-primary'
|
|
1163
|
+
)}
|
|
1164
|
+
onClick={() => setIconValue(iconName)}
|
|
1165
|
+
>
|
|
1166
|
+
<IconOption className="size-5" />
|
|
1167
|
+
</button>
|
|
1168
|
+
);
|
|
1169
|
+
})}
|
|
1170
|
+
</div>
|
|
1171
|
+
</div>
|
|
1172
|
+
</div>
|
|
1173
|
+
|
|
1174
|
+
<div className="mt-auto flex justify-end gap-2 border-t px-6 py-4">
|
|
1175
|
+
<Button
|
|
1176
|
+
className="w-full"
|
|
1177
|
+
onClick={() => void handleSaveDashboardIcon()}
|
|
1178
|
+
disabled={isSavingIcon || Boolean(iconValue.trim()) === false}
|
|
1179
|
+
>
|
|
1180
|
+
{isSavingIcon ? (
|
|
1181
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1182
|
+
) : (
|
|
1183
|
+
<Palette className="size-4" />
|
|
1184
|
+
)}
|
|
1185
|
+
{t('saveIcon')}
|
|
1186
|
+
</Button>
|
|
1187
|
+
</div>
|
|
1188
|
+
</div>
|
|
1189
|
+
</SheetContent>
|
|
1190
|
+
</Sheet>
|
|
1191
|
+
|
|
1192
|
+
<Sheet
|
|
1193
|
+
open={shareOpen}
|
|
1194
|
+
onOpenChange={(open) => {
|
|
1195
|
+
setShareOpen(open);
|
|
1196
|
+
if (!open) {
|
|
1197
|
+
setShareSearch('');
|
|
1198
|
+
}
|
|
1199
|
+
}}
|
|
1200
|
+
>
|
|
1201
|
+
<SheetContent className="w-full sm:max-w-xl">
|
|
1202
|
+
<SheetHeader>
|
|
1203
|
+
<SheetTitle>
|
|
1204
|
+
{t('shareDashboardTitle', {
|
|
1205
|
+
name: activeDashboard ? getDashboardName(activeDashboard) : '',
|
|
1206
|
+
})}
|
|
1207
|
+
</SheetTitle>
|
|
1208
|
+
<SheetDescription>
|
|
1209
|
+
{t('shareDashboardDescription')}
|
|
1210
|
+
</SheetDescription>
|
|
1211
|
+
</SheetHeader>
|
|
1212
|
+
|
|
1213
|
+
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4">
|
|
1214
|
+
<div className="space-y-2">
|
|
1215
|
+
<label className="text-sm font-medium" htmlFor="share-search">
|
|
1216
|
+
{t('searchUser')}
|
|
1217
|
+
</label>
|
|
1218
|
+
<Input
|
|
1219
|
+
id="share-search"
|
|
1220
|
+
value={shareSearch}
|
|
1221
|
+
onChange={(event) => setShareSearch(event.target.value)}
|
|
1222
|
+
placeholder={t('searchUserPlaceholder')}
|
|
1223
|
+
/>
|
|
1224
|
+
</div>
|
|
1225
|
+
|
|
1226
|
+
<div className="grid flex-1 gap-4 overflow-hidden lg:grid-cols-2">
|
|
1227
|
+
<div className="flex min-h-0 flex-col rounded-lg border">
|
|
1228
|
+
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
1229
|
+
<div>
|
|
1230
|
+
<p className="text-sm font-semibold">
|
|
1231
|
+
{t('usersWithAccess')}
|
|
1232
|
+
</p>
|
|
1233
|
+
<p className="text-muted-foreground text-xs">
|
|
1234
|
+
{t('usersWithAccessDescription')}
|
|
1235
|
+
</p>
|
|
1236
|
+
</div>
|
|
1237
|
+
<Badge variant="outline">{sharedUsers.length}</Badge>
|
|
1238
|
+
</div>
|
|
1239
|
+
|
|
1240
|
+
<ScrollArea className="h-72">
|
|
1241
|
+
<div className="space-y-2 p-3">
|
|
1242
|
+
{isLoadingShares ? (
|
|
1243
|
+
<>
|
|
1244
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
1245
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
1246
|
+
</>
|
|
1247
|
+
) : sharedUsers.length === 0 ? (
|
|
1248
|
+
<p className="text-muted-foreground text-sm">
|
|
1249
|
+
{t('noSharedUsers')}
|
|
1250
|
+
</p>
|
|
1251
|
+
) : (
|
|
1252
|
+
sharedUsers.map((user) => (
|
|
1253
|
+
<div
|
|
1254
|
+
key={user.id}
|
|
1255
|
+
className="flex items-center justify-between rounded-lg border p-3"
|
|
1256
|
+
>
|
|
1257
|
+
<div className="min-w-0">
|
|
1258
|
+
<div className="flex items-center gap-2">
|
|
1259
|
+
<p className="truncate text-sm font-medium">
|
|
1260
|
+
{user.name}
|
|
1261
|
+
</p>
|
|
1262
|
+
{user.isCurrentUser ? (
|
|
1263
|
+
<Badge variant="secondary">{t('you')}</Badge>
|
|
1264
|
+
) : null}
|
|
1265
|
+
{user.isHome ? (
|
|
1266
|
+
<Badge variant="outline">{t('home')}</Badge>
|
|
1267
|
+
) : null}
|
|
1268
|
+
</div>
|
|
1269
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
1270
|
+
{user.email || t('noPublicEmail')}
|
|
1271
|
+
</p>
|
|
1272
|
+
</div>
|
|
1273
|
+
|
|
1274
|
+
{!user.isCurrentUser ? (
|
|
1275
|
+
<Button
|
|
1276
|
+
variant="ghost"
|
|
1277
|
+
size="sm"
|
|
1278
|
+
className="cursor-pointer"
|
|
1279
|
+
onClick={() => void handleRevokeShare(user.id)}
|
|
1280
|
+
disabled={revokingUserId === user.id}
|
|
1281
|
+
>
|
|
1282
|
+
{revokingUserId === user.id ? (
|
|
1283
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1284
|
+
) : (
|
|
1285
|
+
<Trash2 className="size-4" />
|
|
1286
|
+
)}
|
|
1287
|
+
</Button>
|
|
1288
|
+
) : null}
|
|
1289
|
+
</div>
|
|
1290
|
+
))
|
|
1291
|
+
)}
|
|
1292
|
+
</div>
|
|
1293
|
+
</ScrollArea>
|
|
1294
|
+
</div>
|
|
1295
|
+
|
|
1296
|
+
<div className="flex min-h-0 flex-col rounded-lg border">
|
|
1297
|
+
<div className="border-b px-3 py-2">
|
|
1298
|
+
<p className="text-sm font-semibold">{t('addPeople')}</p>
|
|
1299
|
+
<p className="text-muted-foreground text-xs">
|
|
1300
|
+
{t('addPeopleDescription')}
|
|
1301
|
+
</p>
|
|
1302
|
+
</div>
|
|
1303
|
+
|
|
1304
|
+
<ScrollArea className="h-72">
|
|
1305
|
+
<div className="space-y-2 p-3">
|
|
1306
|
+
{isLoadingShareableUsers ? (
|
|
1307
|
+
<>
|
|
1308
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
1309
|
+
<Skeleton className="h-16 rounded-lg" />
|
|
1310
|
+
</>
|
|
1311
|
+
) : shareableUsers.length === 0 ? (
|
|
1312
|
+
<p className="text-muted-foreground text-sm">
|
|
1313
|
+
{t('noUsersToShare')}
|
|
1314
|
+
</p>
|
|
1315
|
+
) : (
|
|
1316
|
+
shareableUsers.map((user) => (
|
|
1317
|
+
<div
|
|
1318
|
+
key={user.id}
|
|
1319
|
+
className="flex items-center justify-between rounded-lg border p-3"
|
|
1320
|
+
>
|
|
1321
|
+
<div className="min-w-0">
|
|
1322
|
+
<p className="truncate text-sm font-medium">
|
|
1323
|
+
{user.name}
|
|
1324
|
+
</p>
|
|
1325
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
1326
|
+
{user.email || t('noPublicEmail')}
|
|
1327
|
+
</p>
|
|
1328
|
+
</div>
|
|
1329
|
+
|
|
1330
|
+
<Button
|
|
1331
|
+
size="sm"
|
|
1332
|
+
className="cursor-pointer"
|
|
1333
|
+
onClick={() => void handleShareWithUser(user.id)}
|
|
1334
|
+
disabled={sharingUserId === user.id}
|
|
1335
|
+
>
|
|
1336
|
+
{sharingUserId === user.id ? (
|
|
1337
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1338
|
+
) : (
|
|
1339
|
+
<UserPlus className="size-4" />
|
|
1340
|
+
)}
|
|
1341
|
+
{t('add')}
|
|
1342
|
+
</Button>
|
|
1343
|
+
</div>
|
|
1344
|
+
))
|
|
1345
|
+
)}
|
|
1346
|
+
</div>
|
|
1347
|
+
</ScrollArea>
|
|
1348
|
+
</div>
|
|
1349
|
+
</div>
|
|
1350
|
+
</div>
|
|
1351
|
+
</SheetContent>
|
|
1352
|
+
</Sheet>
|
|
1353
|
+
|
|
1354
|
+
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
1355
|
+
<AlertDialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
|
|
1356
|
+
<AlertDialogHeader>
|
|
1357
|
+
<AlertDialogTitle>{t('removeTabTitle')}</AlertDialogTitle>
|
|
1358
|
+
<AlertDialogDescription>
|
|
1359
|
+
{activeDashboard
|
|
1360
|
+
? t('removeTabDescription', {
|
|
1361
|
+
name: getDashboardName(activeDashboard),
|
|
1362
|
+
})
|
|
1363
|
+
: t('removeTabDescriptionFallback')}
|
|
1364
|
+
</AlertDialogDescription>
|
|
1365
|
+
</AlertDialogHeader>
|
|
1366
|
+
<AlertDialogFooter>
|
|
1367
|
+
<AlertDialogCancel disabled={isDeleting}>
|
|
1368
|
+
{t('cancel')}
|
|
1369
|
+
</AlertDialogCancel>
|
|
1370
|
+
<AlertDialogAction
|
|
1371
|
+
onClick={(event) => {
|
|
1372
|
+
event.preventDefault();
|
|
1373
|
+
void handleRemoveDashboard();
|
|
1374
|
+
}}
|
|
1375
|
+
disabled={isDeleting}
|
|
1376
|
+
>
|
|
1377
|
+
{isDeleting ? (
|
|
1378
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1379
|
+
) : (
|
|
1380
|
+
<Trash2 className="size-4" />
|
|
1381
|
+
)}
|
|
1382
|
+
{t('removeTab')}
|
|
1383
|
+
</AlertDialogAction>
|
|
1384
|
+
</AlertDialogFooter>
|
|
1385
|
+
</AlertDialogContent>
|
|
1386
|
+
</AlertDialog>
|
|
1387
|
+
</Page>
|
|
1388
|
+
);
|
|
1389
|
+
}
|