@hed-hog/core 0.0.304 → 0.0.306

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.
Files changed (112) hide show
  1. package/README.md +84 -36
  2. package/dist/auth/auth.controller.d.ts +0 -3
  3. package/dist/auth/auth.controller.d.ts.map +1 -1
  4. package/dist/auth/auth.controller.js +4 -7
  5. package/dist/auth/auth.controller.js.map +1 -1
  6. package/dist/auth/auth.service.d.ts.map +1 -1
  7. package/dist/auth/auth.service.js +6 -3
  8. package/dist/auth/auth.service.js.map +1 -1
  9. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +9 -0
  10. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
  11. package/dist/dashboard/dashboard-core/dashboard-core.controller.js +12 -0
  12. package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
  13. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +7 -0
  14. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
  15. package/dist/dashboard/dashboard-core/dashboard-core.service.js +45 -1
  16. package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
  17. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts +9 -0
  18. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts.map +1 -0
  19. package/dist/mail-sent/dto/query-mail-sent-list.dto.js +49 -0
  20. package/dist/mail-sent/dto/query-mail-sent-list.dto.js.map +1 -0
  21. package/dist/mail-sent/mail-sent.controller.d.ts +24 -2
  22. package/dist/mail-sent/mail-sent.controller.d.ts.map +1 -1
  23. package/dist/mail-sent/mail-sent.controller.js +5 -3
  24. package/dist/mail-sent/mail-sent.controller.js.map +1 -1
  25. package/dist/mail-sent/mail-sent.service.d.ts +28 -3
  26. package/dist/mail-sent/mail-sent.service.d.ts.map +1 -1
  27. package/dist/mail-sent/mail-sent.service.js +108 -3
  28. package/dist/mail-sent/mail-sent.service.js.map +1 -1
  29. package/dist/oauth/oauth-callback-coordinator.service.d.ts +18 -0
  30. package/dist/oauth/oauth-callback-coordinator.service.d.ts.map +1 -0
  31. package/dist/oauth/oauth-callback-coordinator.service.js +137 -0
  32. package/dist/oauth/oauth-callback-coordinator.service.js.map +1 -0
  33. package/dist/oauth/oauth.controller.d.ts +4 -2
  34. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  35. package/dist/oauth/oauth.controller.js +39 -11
  36. package/dist/oauth/oauth.controller.js.map +1 -1
  37. package/dist/oauth/oauth.errors.d.ts +16 -0
  38. package/dist/oauth/oauth.errors.d.ts.map +1 -0
  39. package/dist/oauth/oauth.errors.js +30 -0
  40. package/dist/oauth/oauth.errors.js.map +1 -0
  41. package/dist/oauth/oauth.module.d.ts.map +1 -1
  42. package/dist/oauth/oauth.module.js +10 -1
  43. package/dist/oauth/oauth.module.js.map +1 -1
  44. package/dist/oauth/oauth.service.d.ts +9 -1
  45. package/dist/oauth/oauth.service.d.ts.map +1 -1
  46. package/dist/oauth/oauth.service.js +97 -24
  47. package/dist/oauth/oauth.service.js.map +1 -1
  48. package/dist/oauth/providers/abstract.provider.d.ts +6 -0
  49. package/dist/oauth/providers/abstract.provider.d.ts.map +1 -1
  50. package/dist/oauth/providers/abstract.provider.js +34 -0
  51. package/dist/oauth/providers/abstract.provider.js.map +1 -1
  52. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +3 -0
  53. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  54. package/dist/oauth/providers/microsoft-entra-id.provider.js +40 -22
  55. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  56. package/dist/security/security.service.d.ts +1 -0
  57. package/dist/security/security.service.d.ts.map +1 -1
  58. package/dist/security/security.service.js +9 -10
  59. package/dist/security/security.service.js.map +1 -1
  60. package/dist/session/session.service.d.ts +5 -1
  61. package/dist/session/session.service.d.ts.map +1 -1
  62. package/dist/session/session.service.js +76 -10
  63. package/dist/session/session.service.js.map +1 -1
  64. package/dist/setting/setting.service.js +4 -4
  65. package/dist/setting/setting.service.js.map +1 -1
  66. package/dist/token/token.service.d.ts +1 -0
  67. package/dist/token/token.service.d.ts.map +1 -1
  68. package/dist/token/token.service.js +23 -12
  69. package/dist/token/token.service.js.map +1 -1
  70. package/dist/user/user.service.d.ts +1 -1
  71. package/dist/user/user.service.d.ts.map +1 -1
  72. package/dist/user/user.service.js +3 -0
  73. package/dist/user/user.service.js.map +1 -1
  74. package/hedhog/data/dashboard.yaml +2 -2
  75. package/hedhog/data/route.yaml +8 -0
  76. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +87 -8
  77. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +90 -10
  78. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +145 -31
  79. package/hedhog/frontend/app/configurations/layout.tsx.ejs +1 -1
  80. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +67 -2
  81. package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +13 -9
  82. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +250 -45
  83. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +111 -15
  84. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +110 -15
  85. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +113 -15
  86. package/hedhog/frontend/app/mail/log/page.tsx.ejs +481 -70
  87. package/hedhog/frontend/app/menu/page.tsx.ejs +202 -30
  88. package/hedhog/frontend/app/roles/menus.tsx.ejs +1 -1
  89. package/hedhog/frontend/app/roles/page.tsx.ejs +288 -104
  90. package/hedhog/frontend/app/users/page.tsx.ejs +168 -23
  91. package/hedhog/frontend/messages/en.json +45 -6
  92. package/hedhog/frontend/messages/pt.json +45 -6
  93. package/package.json +5 -5
  94. package/src/auth/auth.controller.ts +15 -19
  95. package/src/auth/auth.service.ts +13 -8
  96. package/src/dashboard/dashboard-core/dashboard-core.controller.ts +22 -9
  97. package/src/dashboard/dashboard-core/dashboard-core.service.ts +75 -1
  98. package/src/mail-sent/dto/query-mail-sent-list.dto.ts +30 -0
  99. package/src/mail-sent/mail-sent.controller.ts +14 -2
  100. package/src/mail-sent/mail-sent.service.ts +159 -4
  101. package/src/oauth/oauth-callback-coordinator.service.ts +158 -0
  102. package/src/oauth/oauth.controller.ts +58 -1
  103. package/src/oauth/oauth.errors.ts +32 -0
  104. package/src/oauth/oauth.module.ts +10 -1
  105. package/src/oauth/oauth.service.ts +178 -28
  106. package/src/oauth/providers/abstract.provider.ts +64 -0
  107. package/src/oauth/providers/microsoft-entra-id.provider.ts +92 -56
  108. package/src/security/security.service.ts +12 -10
  109. package/src/session/session.service.ts +84 -9
  110. package/src/setting/setting.service.ts +10 -10
  111. package/src/token/token.service.ts +29 -11
  112. package/src/user/user.service.ts +10 -6
@@ -39,6 +39,13 @@ interface DashboardContentProps {
39
39
  dashboardSlug: string;
40
40
  showHeader?: boolean;
41
41
  headerActionsTargetId?: string;
42
+ externalHeaderActions?: boolean;
43
+ requestSaveSignal?: number;
44
+ onRuntimeActionsStateChange?: (state: {
45
+ hasChanges: boolean;
46
+ isSaving: boolean;
47
+ isAutosavePending: boolean;
48
+ }) => void;
42
49
  openWidgetPickerSignal?: number;
43
50
  onOpenWidgetPickerHandled?: () => void;
44
51
  }
@@ -154,6 +161,9 @@ export const DashboardContent = ({
154
161
  dashboardSlug,
155
162
  showHeader = true,
156
163
  headerActionsTargetId,
164
+ externalHeaderActions = false,
165
+ requestSaveSignal,
166
+ onRuntimeActionsStateChange,
157
167
  openWidgetPickerSignal,
158
168
  onOpenWidgetPickerHandled,
159
169
  }: DashboardContentProps) => {
@@ -180,6 +190,7 @@ export const DashboardContent = ({
180
190
  useState<HTMLElement | null>(null);
181
191
  const lastSavedLayoutSignatureRef = useRef('[]');
182
192
  const latestLayoutRef = useRef(layout);
193
+ const lastHandledSaveSignalRef = useRef<number | undefined>(undefined);
183
194
 
184
195
  const debouncedComponentsSearch = useDebounce(componentsSearchQuery, 400);
185
196
  const debouncedLayout = useDebounce(layout, LAYOUT_AUTOSAVE_DELAY);
@@ -316,6 +327,14 @@ export const DashboardContent = ({
316
327
  const isAutosavePending =
317
328
  hasChanges && !isSaving && layoutSignature !== debouncedLayoutSignature;
318
329
 
330
+ useEffect(() => {
331
+ onRuntimeActionsStateChange?.({
332
+ hasChanges,
333
+ isSaving,
334
+ isAutosavePending,
335
+ });
336
+ }, [hasChanges, isAutosavePending, isSaving, onRuntimeActionsStateChange]);
337
+
319
338
  useEffect(() => {
320
339
  if (!isAutosavePending && !isSaving) {
321
340
  stopProgress(150);
@@ -394,6 +413,22 @@ export const DashboardContent = ({
394
413
  [dashboardSlug, isSaving, layout, request]
395
414
  );
396
415
 
416
+ useEffect(() => {
417
+ if (!requestSaveSignal) {
418
+ return;
419
+ }
420
+
421
+ if (lastHandledSaveSignalRef.current === requestSaveSignal) {
422
+ return;
423
+ }
424
+
425
+ lastHandledSaveSignalRef.current = requestSaveSignal;
426
+
427
+ if (!isSaving && hasChanges) {
428
+ void handleSaveLayout();
429
+ }
430
+ }, [handleSaveLayout, hasChanges, isSaving, requestSaveSignal]);
431
+
397
432
  useEffect(() => {
398
433
  if (!hasChanges || isSaving) {
399
434
  return;
@@ -646,7 +681,8 @@ export const DashboardContent = ({
646
681
  </>
647
682
  );
648
683
 
649
- const hasExternalActionTarget = !showHeader && Boolean(headerActionsTarget);
684
+ const hasExternalActionTarget =
685
+ !externalHeaderActions && !showHeader && Boolean(headerActionsTarget);
650
686
 
651
687
  return (
652
688
  <>
@@ -678,7 +714,7 @@ export const DashboardContent = ({
678
714
  </div>
679
715
  </div>
680
716
  </header>
681
- ) : !hasExternalActionTarget ? (
717
+ ) : !externalHeaderActions && !hasExternalActionTarget ? (
682
718
  <div className="flex w-full items-center justify-end gap-2 px-4 pt-2">
683
719
  {dashboardActions}
684
720
  </div>
@@ -686,6 +722,35 @@ export const DashboardContent = ({
686
722
  {hasExternalActionTarget && headerActionsTarget
687
723
  ? createPortal(dashboardActions, headerActionsTarget)
688
724
  : null}
725
+ {externalHeaderActions ? (
726
+ <AddWidgetSelectorDialog
727
+ availableComponents={availableComponents}
728
+ totalItems={totalAvailableComponents}
729
+ currentPage={availableComponentsResponse?.page ?? componentsPage}
730
+ pageSize={availableComponentsResponse?.pageSize ?? componentsPageSize}
731
+ isLoading={isLoadingComponents}
732
+ searchQuery={componentsSearchQuery}
733
+ onSearchQueryChange={(value) => {
734
+ setComponentsSearchQuery(value);
735
+ setComponentsPage(1);
736
+ }}
737
+ moduleFilter={componentsModuleFilter}
738
+ modules={availableComponentModules}
739
+ onModuleFilterChange={(value) => {
740
+ setComponentsModuleFilter(value);
741
+ setComponentsPage(1);
742
+ }}
743
+ onPageChange={setComponentsPage}
744
+ onPageSizeChange={(nextPageSize) => {
745
+ setComponentsPageSize(nextPageSize);
746
+ setComponentsPage(1);
747
+ }}
748
+ onAdd={handleAddWidget}
749
+ hideTrigger
750
+ openSignal={openWidgetPickerSignal}
751
+ onOpenSignalHandled={onOpenWidgetPickerHandled}
752
+ />
753
+ ) : null}
689
754
  <div className="flex flex-1 flex-col gap-4 overflow-auto pt-0">
690
755
  {widgets.length > 0 ? (
691
756
  <div className="min-h-105 sm:min-h-130 lg:min-h-150">
@@ -64,6 +64,7 @@ interface AddWidgetSelectorDialogProps {
64
64
  onAdd: (slugs: string[]) => Promise<void> | void;
65
65
  buttonLabel?: string;
66
66
  buttonVariant?: 'default' | 'outline';
67
+ hideTrigger?: boolean;
67
68
  openSignal?: number;
68
69
  onOpenSignalHandled?: () => void;
69
70
  }
@@ -153,6 +154,7 @@ export function AddWidgetSelectorDialog({
153
154
  onAdd,
154
155
  buttonLabel,
155
156
  buttonVariant = 'outline',
157
+ hideTrigger = false,
156
158
  openSignal,
157
159
  onOpenSignalHandled,
158
160
  }: AddWidgetSelectorDialogProps) {
@@ -210,15 +212,17 @@ export function AddWidgetSelectorDialog({
210
212
 
211
213
  return (
212
214
  <>
213
- <Button
214
- size="sm"
215
- variant={buttonVariant}
216
- className="cursor-pointer gap-2"
217
- onClick={() => setOpenWidgets(true)}
218
- >
219
- <IconPlus className="size-4" />
220
- {buttonLabel || tMenu('addWidgets')}
221
- </Button>
215
+ {!hideTrigger ? (
216
+ <Button
217
+ size="sm"
218
+ variant={buttonVariant}
219
+ className="cursor-pointer gap-2"
220
+ onClick={() => setOpenWidgets(true)}
221
+ >
222
+ <IconPlus className="size-4" />
223
+ {buttonLabel || tMenu('addWidgets')}
224
+ </Button>
225
+ ) : null}
222
226
 
223
227
  <Sheet
224
228
  open={openWidgets}
@@ -42,15 +42,19 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
42
42
  import { useDebounce } from '@/hooks/use-debounce';
43
43
  import { cn } from '@/lib/utils';
44
44
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
+ import useEmblaCarousel from 'embla-carousel-react';
45
46
  import * as LucideIcons from 'lucide-react';
46
47
  import {
47
48
  Check,
49
+ ChevronLeft,
50
+ ChevronRight,
48
51
  Home,
49
52
  LayoutDashboard,
50
53
  Loader2,
51
54
  Palette,
52
55
  Pencil,
53
56
  Plus,
57
+ Save,
54
58
  Share2,
55
59
  Trash2,
56
60
  UserPlus,
@@ -58,7 +62,12 @@ import {
58
62
  type LucideIcon,
59
63
  } from 'lucide-react';
60
64
  import { useTranslations } from 'next-intl';
61
- import { useEffect, useMemo, useState } from 'react';
65
+ import {
66
+ useEffect,
67
+ useMemo,
68
+ useState,
69
+ type DragEvent as ReactDragEvent,
70
+ } from 'react';
62
71
  import { toast } from 'sonner';
63
72
  import { DashboardContent } from './[slug]/dashboard-content';
64
73
 
@@ -104,7 +113,22 @@ type DashboardTemplate = {
104
113
  const getDashboardName = (dashboard: DashboardTab) =>
105
114
  dashboard.name || dashboard.dashboard_locale?.[0]?.name || dashboard.slug;
106
115
 
107
- const DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID = 'dashboard-home-header-actions';
116
+ const reorderDashboards = (
117
+ currentDashboards: DashboardTab[],
118
+ sourceIndex: number,
119
+ targetIndex: number
120
+ ) => {
121
+ const nextDashboards = [...currentDashboards];
122
+ const [movedDashboard] = nextDashboards.splice(sourceIndex, 1);
123
+
124
+ if (!movedDashboard) {
125
+ return currentDashboards;
126
+ }
127
+
128
+ nextDashboards.splice(targetIndex, 0, movedDashboard);
129
+ return nextDashboards;
130
+ };
131
+
108
132
  const EMPTY_SHAREABLE_USERS_PAGE: PaginatedResponse<SharedUser> = {
109
133
  data: [],
110
134
  total: 0,
@@ -289,6 +313,7 @@ const getErrorMessage = (error: unknown, fallback: string) => {
289
313
 
290
314
  export function DashboardHomeTabs() {
291
315
  const t = useTranslations('core.DashboardHomeTabs');
316
+ const tDashboardPage = useTranslations('core.DashboardPage');
292
317
  const { request, currentLocaleCode } = useApp();
293
318
  const [activeSlug, setActiveSlug] = useState('');
294
319
  const [createOpen, setCreateOpen] = useState(false);
@@ -315,6 +340,27 @@ export function DashboardHomeTabs() {
315
340
  const [iconDashboard, setIconDashboard] = useState<DashboardTab | null>(null);
316
341
  const [iconValue, setIconValue] = useState('layout-dashboard');
317
342
  const [isSavingIcon, setIsSavingIcon] = useState(false);
343
+ const [requestSaveLayoutSignal, setRequestSaveLayoutSignal] = useState(0);
344
+ const [runtimeHeaderState, setRuntimeHeaderState] = useState({
345
+ hasChanges: false,
346
+ isSaving: false,
347
+ isAutosavePending: false,
348
+ });
349
+
350
+ const [tabsEmblaRef, tabsEmblaApi] = useEmblaCarousel({
351
+ dragFree: true,
352
+ containScroll: 'keepSnaps',
353
+ align: 'start',
354
+ });
355
+ const [canScrollPrevTabs, setCanScrollPrevTabs] = useState(false);
356
+ const [canScrollNextTabs, setCanScrollNextTabs] = useState(false);
357
+ const [orderedDashboards, setOrderedDashboards] = useState<DashboardTab[]>(
358
+ []
359
+ );
360
+ const [draggingDashboardSlug, setDraggingDashboardSlug] = useState<
361
+ string | null
362
+ >(null);
363
+ const [isReordering, setIsReordering] = useState(false);
318
364
 
319
365
  const debouncedShareSearch = useDebounce(shareSearch, 300);
320
366
 
@@ -349,8 +395,10 @@ export function DashboardHomeTabs() {
349
395
  });
350
396
 
351
397
  const activeDashboard = useMemo(
352
- () => dashboards.find((dashboard) => dashboard.slug === activeSlug) ?? null,
353
- [activeSlug, dashboards]
398
+ () =>
399
+ orderedDashboards.find((dashboard) => dashboard.slug === activeSlug) ??
400
+ null,
401
+ [activeSlug, orderedDashboards]
354
402
  );
355
403
 
356
404
  const selectedTemplate = useMemo(
@@ -372,24 +420,47 @@ export function DashboardHomeTabs() {
372
420
  );
373
421
 
374
422
  useEffect(() => {
375
- if (dashboards.length === 0) {
423
+ setOrderedDashboards(dashboards);
424
+ }, [dashboards]);
425
+
426
+ useEffect(() => {
427
+ if (orderedDashboards.length === 0) {
376
428
  setActiveSlug('');
377
429
  return;
378
430
  }
379
431
 
380
- const hasActiveDashboard = dashboards.some(
432
+ const hasActiveDashboard = orderedDashboards.some(
381
433
  (dashboard) => dashboard.slug === activeSlug
382
434
  );
383
435
 
384
436
  if (!activeSlug || !hasActiveDashboard) {
385
437
  const nextDashboard =
386
- dashboards.find((dashboard) => dashboard.is_home) ?? dashboards[0];
438
+ orderedDashboards.find((dashboard) => dashboard.is_home) ??
439
+ orderedDashboards[0];
387
440
 
388
441
  if (nextDashboard) {
389
442
  setActiveSlug(nextDashboard.slug);
390
443
  }
391
444
  }
392
- }, [activeSlug, dashboards]);
445
+ }, [activeSlug, orderedDashboards]);
446
+
447
+ useEffect(() => {
448
+ if (!tabsEmblaApi) return;
449
+
450
+ const updateScrollButtons = () => {
451
+ setCanScrollPrevTabs(tabsEmblaApi.canScrollPrev());
452
+ setCanScrollNextTabs(tabsEmblaApi.canScrollNext());
453
+ };
454
+
455
+ updateScrollButtons();
456
+ tabsEmblaApi.on('scroll', updateScrollButtons);
457
+ tabsEmblaApi.on('reInit', updateScrollButtons);
458
+
459
+ return () => {
460
+ tabsEmblaApi.off('scroll', updateScrollButtons);
461
+ tabsEmblaApi.off('reInit', updateScrollButtons);
462
+ };
463
+ }, [tabsEmblaApi]);
393
464
 
394
465
  const {
395
466
  data: sharedUsers = [],
@@ -462,6 +533,80 @@ export function DashboardHomeTabs() {
462
533
 
463
534
  const shareableUsers = shareableUsersPage.data ?? [];
464
535
 
536
+ const handleTabDragStart = (
537
+ event: ReactDragEvent<HTMLElement>,
538
+ dashboardSlug: string
539
+ ) => {
540
+ setDraggingDashboardSlug(dashboardSlug);
541
+ event.dataTransfer.effectAllowed = 'move';
542
+ event.dataTransfer.setData('text/plain', dashboardSlug);
543
+ };
544
+
545
+ const handleTabDragOver = (event: ReactDragEvent<HTMLElement>) => {
546
+ event.preventDefault();
547
+ event.dataTransfer.dropEffect = 'move';
548
+ };
549
+
550
+ const handleTabDrop = async (
551
+ event: ReactDragEvent<HTMLElement>,
552
+ targetDashboardSlug: string
553
+ ) => {
554
+ event.preventDefault();
555
+
556
+ const sourceDashboardSlug =
557
+ draggingDashboardSlug || event.dataTransfer.getData('text/plain');
558
+
559
+ if (!sourceDashboardSlug || sourceDashboardSlug === targetDashboardSlug) {
560
+ setDraggingDashboardSlug(null);
561
+ return;
562
+ }
563
+
564
+ const sourceIndex = orderedDashboards.findIndex(
565
+ (dashboard) => dashboard.slug === sourceDashboardSlug
566
+ );
567
+ const targetIndex = orderedDashboards.findIndex(
568
+ (dashboard) => dashboard.slug === targetDashboardSlug
569
+ );
570
+
571
+ if (sourceIndex < 0 || targetIndex < 0) {
572
+ setDraggingDashboardSlug(null);
573
+ return;
574
+ }
575
+
576
+ const previousDashboards = orderedDashboards;
577
+ const nextDashboards = reorderDashboards(
578
+ orderedDashboards,
579
+ sourceIndex,
580
+ targetIndex
581
+ );
582
+
583
+ setOrderedDashboards(nextDashboards);
584
+
585
+ try {
586
+ setIsReordering(true);
587
+ await request({
588
+ url: '/dashboard-core/dashboard/order',
589
+ method: 'PATCH',
590
+ data: {
591
+ slugs: nextDashboards.map((dashboard) => dashboard.slug),
592
+ },
593
+ });
594
+ await refetchDashboards();
595
+ } catch (error) {
596
+ setOrderedDashboards(previousDashboards);
597
+ toast.error(
598
+ getErrorMessage(error, 'Unable to save dashboard tab order.')
599
+ );
600
+ } finally {
601
+ setIsReordering(false);
602
+ setDraggingDashboardSlug(null);
603
+ }
604
+ };
605
+
606
+ const handleTabDragEnd = () => {
607
+ setDraggingDashboardSlug(null);
608
+ };
609
+
465
610
  const handleSelectTemplate = (template: DashboardTemplate | null) => {
466
611
  setSelectedTemplateSlug(template?.slug ?? '');
467
612
  setNewDashboardName(template?.name ?? '');
@@ -738,34 +883,9 @@ export function DashboardHomeTabs() {
738
883
  isSettingHome ||
739
884
  isSavingIcon ||
740
885
  isSharingUsers ||
886
+ isReordering ||
741
887
  revokingUserId !== null;
742
888
 
743
- const headerActions = (
744
- <div className="flex flex-wrap items-center justify-end gap-2">
745
- {activeDashboard ? (
746
- <div
747
- id={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
748
- className="contents"
749
- />
750
- ) : null}
751
- <Button
752
- variant="outline"
753
- size="sm"
754
- onClick={() =>
755
- activeDashboard && openShareForDashboard(activeDashboard)
756
- }
757
- disabled={!activeDashboard || isBusy}
758
- >
759
- <Share2 className="size-4" />
760
- {t('share')}
761
- </Button>
762
- <Button size="sm" onClick={() => setCreateOpen(true)} disabled={isBusy}>
763
- <Plus className="size-4" />
764
- {t('newDashboard')}
765
- </Button>
766
- </div>
767
- );
768
-
769
889
  return (
770
890
  <Page>
771
891
  <PageHeader
@@ -777,7 +897,51 @@ export function DashboardHomeTabs() {
777
897
  ]}
778
898
  title={t('pageTitle')}
779
899
  description={t('pageDescription')}
780
- actions={headerActions}
900
+ actions={[
901
+ {
902
+ label: t('share'),
903
+ onClick: () =>
904
+ activeDashboard && openShareForDashboard(activeDashboard),
905
+ icon: <Share2 className="size-4" />,
906
+ variant: 'outline',
907
+ size: 'sm',
908
+ disabled: !activeDashboard || isBusy,
909
+ },
910
+ {
911
+ label: t('addWidgets'),
912
+ onClick: () =>
913
+ activeDashboard && openAddWidgetsForDashboard(activeDashboard),
914
+ icon: <Plus className="size-4" />,
915
+ variant: 'outline',
916
+ size: 'sm',
917
+ disabled: !activeDashboard || isBusy,
918
+ },
919
+ {
920
+ label: runtimeHeaderState.isSaving
921
+ ? tDashboardPage('saving')
922
+ : tDashboardPage('saveLayout'),
923
+ onClick: () =>
924
+ setRequestSaveLayoutSignal((previous) => previous + 1),
925
+ icon: runtimeHeaderState.isSaving ? (
926
+ <Loader2 className="size-4 animate-spin" />
927
+ ) : (
928
+ <Save className="size-4" />
929
+ ),
930
+ size: 'sm',
931
+ disabled:
932
+ !activeDashboard ||
933
+ isBusy ||
934
+ runtimeHeaderState.isSaving ||
935
+ !runtimeHeaderState.hasChanges,
936
+ },
937
+ {
938
+ label: t('newDashboard'),
939
+ onClick: () => setCreateOpen(true),
940
+ icon: <Plus className="size-4" />,
941
+ size: 'sm',
942
+ disabled: isBusy,
943
+ },
944
+ ]}
781
945
  />
782
946
 
783
947
  {isLoading ? (
@@ -793,10 +957,20 @@ export function DashboardHomeTabs() {
793
957
  onValueChange={setActiveSlug}
794
958
  className="gap-0"
795
959
  >
796
- <div className="bg-muted dark:bg-muted/40">
797
- <ScrollArea className="w-full whitespace-nowrap">
798
- <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">
799
- {dashboards.map((dashboard) => {
960
+ <div className="bg-muted dark:bg-muted/40 relative">
961
+ {canScrollPrevTabs && (
962
+ <button
963
+ type="button"
964
+ onClick={() => tabsEmblaApi?.scrollPrev()}
965
+ aria-label="Scroll tabs left"
966
+ className="md:hidden absolute left-0 top-0 bottom-0 z-10 flex items-center px-1.5 bg-linear-to-r from-muted dark:from-muted/40 to-transparent"
967
+ >
968
+ <ChevronLeft className="size-4 text-muted-foreground" />
969
+ </button>
970
+ )}
971
+ <div ref={tabsEmblaRef} className="overflow-hidden">
972
+ <TabsList className="dashboard-tabs-list h-auto min-h-0 items-end gap-0 rounded-none bg-transparent p-0 pt-1 pl-1 flex w-max">
973
+ {orderedDashboards.map((dashboard) => {
800
974
  const isRenamingCurrentTab =
801
975
  renamingSlug === dashboard.slug;
802
976
  const isActiveTab = activeSlug === dashboard.slug;
@@ -846,13 +1020,32 @@ export function DashboardHomeTabs() {
846
1020
  <ContextMenu key={dashboard.slug}>
847
1021
  <ContextMenuTrigger asChild>
848
1022
  {isRenamingCurrentTab ? (
849
- <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">
1023
+ <div
1024
+ onDragOver={handleTabDragOver}
1025
+ onDrop={(event) =>
1026
+ void handleTabDrop(event, dashboard.slug)
1027
+ }
1028
+ 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"
1029
+ >
850
1030
  {tabContent}
851
1031
  </div>
852
1032
  ) : (
853
1033
  <TabsTrigger
854
1034
  value={dashboard.slug}
855
- 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"
1035
+ draggable={!isBusy}
1036
+ onDragStart={(event) =>
1037
+ handleTabDragStart(event, dashboard.slug)
1038
+ }
1039
+ onDragOver={handleTabDragOver}
1040
+ onDrop={(event) =>
1041
+ void handleTabDrop(event, dashboard.slug)
1042
+ }
1043
+ onDragEnd={handleTabDragEnd}
1044
+ className={cn(
1045
+ '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',
1046
+ draggingDashboardSlug === dashboard.slug &&
1047
+ 'opacity-70'
1048
+ )}
856
1049
  style={
857
1050
  isActiveTab
858
1051
  ? {
@@ -934,7 +1127,17 @@ export function DashboardHomeTabs() {
934
1127
  <Plus className="size-4" />
935
1128
  </button>
936
1129
  </TabsList>
937
- </ScrollArea>
1130
+ </div>
1131
+ {canScrollNextTabs && (
1132
+ <button
1133
+ type="button"
1134
+ onClick={() => tabsEmblaApi?.scrollNext()}
1135
+ aria-label="Scroll tabs right"
1136
+ className="md:hidden absolute right-0 top-0 bottom-0 z-10 flex items-center px-1.5 bg-linear-to-l from-muted dark:from-muted/40 to-transparent"
1137
+ >
1138
+ <ChevronRight className="size-4 text-muted-foreground" />
1139
+ </button>
1140
+ )}
938
1141
  </div>
939
1142
  </Tabs>
940
1143
  </div>
@@ -952,7 +1155,9 @@ export function DashboardHomeTabs() {
952
1155
  key={activeDashboard.slug}
953
1156
  dashboardSlug={activeDashboard.slug}
954
1157
  showHeader={false}
955
- headerActionsTargetId={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
1158
+ externalHeaderActions
1159
+ requestSaveSignal={requestSaveLayoutSignal}
1160
+ onRuntimeActionsStateChange={setRuntimeHeaderState}
956
1161
  openWidgetPickerSignal={openAddWidgetSignal}
957
1162
  onOpenWidgetPickerHandled={() => setOpenAddWidgetSignal(0)}
958
1163
  />
@@ -1131,7 +1336,7 @@ export function DashboardHomeTabs() {
1131
1336
 
1132
1337
  <div className="space-y-2">
1133
1338
  <p className="text-sm font-medium">{t('iconSuggestions')}</p>
1134
- <div className="grid gap-2 grid-cols-12">
1339
+ <div className="grid grid-cols-6 gap-2 md:grid-cols-8 lg:grid-cols-12">
1135
1340
  {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1136
1341
  const IconOption = resolveDashboardIcon(iconName);
1137
1342
  const isSelected =
@@ -1241,7 +1446,7 @@ export function DashboardHomeTabs() {
1241
1446
 
1242
1447
  <div className="space-y-2">
1243
1448
  <p className="text-sm font-medium">{t('suggestions')}</p>
1244
- <div className="grid gap-2 grid-cols-12">
1449
+ <div className="grid grid-cols-6 gap-2 md:grid-cols-8 lg:grid-cols-12">
1245
1450
  {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1246
1451
  const IconOption = resolveDashboardIcon(iconName);
1247
1452
  const isSelected =