@tuturuuu/ui 0.30.2 → 0.32.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -0,0 +1,163 @@
1
+ import type { NavLink } from '@tuturuuu/ui/custom/navigation';
2
+ import type { Dispatch, ReactNode, SetStateAction } from 'react';
3
+
4
+ export type SidebarStructureRenderProps = {
5
+ closeOnMobile?: () => void;
6
+ expandSidebar?: () => void;
7
+ isCollapsed: boolean;
8
+ standalone?: boolean;
9
+ setIsCollapsed?: Dispatch<SetStateAction<boolean>>;
10
+ };
11
+
12
+ export type WorkspaceSelectRenderer = (
13
+ props: SidebarStructureRenderProps
14
+ ) => ReactNode;
15
+
16
+ export type NavigationState = {
17
+ currentLinks: (NavLink | null)[];
18
+ direction: 'forward' | 'backward';
19
+ history: (NavLink | null)[][];
20
+ titleHistory: string[];
21
+ };
22
+
23
+ export function matchesPath(
24
+ pathname: string,
25
+ target?: string,
26
+ hasChildren?: boolean
27
+ ) {
28
+ if (!target) return false;
29
+ if (hasChildren) {
30
+ return pathname === target || pathname.startsWith(`${target}/`);
31
+ }
32
+ return pathname === target;
33
+ }
34
+
35
+ function cleanNavigationSeparators(
36
+ items: (NavLink | null | undefined)[]
37
+ ): (NavLink | null)[] {
38
+ const result: (NavLink | null)[] = [];
39
+
40
+ for (const item of items) {
41
+ if (item === undefined) continue;
42
+ if (!item && (result.length === 0 || result.at(-1) === null)) continue;
43
+ result.push(item);
44
+ }
45
+
46
+ while (result.at(-1) === null) result.pop();
47
+ return result;
48
+ }
49
+
50
+ export function getFilteredLinks(
51
+ linksToFilter: (NavLink | null)[] | undefined
52
+ ): (NavLink | null)[] {
53
+ const filtered = (linksToFilter || []).flatMap((link) => {
54
+ if (!link) return [null];
55
+ if (link.disabled) return [];
56
+
57
+ if (link.children && link.children.length > 1) {
58
+ const filteredChildren = getFilteredLinks(link.children);
59
+ if (!filteredChildren.some(Boolean)) return [];
60
+ return [{ ...link, children: filteredChildren }];
61
+ }
62
+
63
+ if (link.children && link.children.length === 1) {
64
+ return getFilteredLinks([link.children[0] as NavLink]);
65
+ }
66
+
67
+ return [link];
68
+ });
69
+
70
+ return cleanNavigationSeparators(filtered);
71
+ }
72
+
73
+ function hasActiveChild(
74
+ navLinks: (NavLink | null)[],
75
+ pathname: string
76
+ ): boolean {
77
+ return navLinks.some((child) => {
78
+ const childMatches =
79
+ (child?.href &&
80
+ matchesPath(pathname, child.href, Boolean(child.children?.length))) ||
81
+ child?.aliases?.some((alias) =>
82
+ matchesPath(pathname, alias, Boolean(child.children?.length))
83
+ );
84
+
85
+ if (childMatches) return true;
86
+ if (child?.children) return hasActiveChild(child.children, pathname);
87
+ return false;
88
+ });
89
+ }
90
+
91
+ export function findActiveNavigation({
92
+ currentPath,
93
+ navLinks,
94
+ }: {
95
+ currentPath: string;
96
+ navLinks: (NavLink | null)[];
97
+ }): NavigationState | null {
98
+ for (const link of navLinks) {
99
+ if (!link) continue;
100
+
101
+ if (link.children?.length) {
102
+ const deeper = findActiveNavigation({
103
+ currentPath,
104
+ navLinks: link.children,
105
+ });
106
+ if (deeper) {
107
+ return {
108
+ currentLinks: deeper.currentLinks,
109
+ direction: 'forward',
110
+ history: [navLinks, ...deeper.history],
111
+ titleHistory: [link.title, ...deeper.titleHistory],
112
+ };
113
+ }
114
+
115
+ if (hasActiveChild(link.children, currentPath)) {
116
+ return {
117
+ currentLinks: link.children,
118
+ direction: 'forward',
119
+ history: [navLinks],
120
+ titleHistory: [link.title],
121
+ };
122
+ }
123
+ }
124
+
125
+ const linkMatches =
126
+ (link.href &&
127
+ matchesPath(currentPath, link.href, Boolean(link.children?.length))) ||
128
+ link.aliases?.some((alias) =>
129
+ matchesPath(currentPath, alias, Boolean(link.children?.length))
130
+ );
131
+
132
+ if (linkMatches && link.children?.length) {
133
+ return {
134
+ currentLinks: link.children,
135
+ direction: 'forward',
136
+ history: [navLinks],
137
+ titleHistory: [link.title],
138
+ };
139
+ }
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ export function getNavigationMatches({
146
+ currentLinks,
147
+ pathname,
148
+ }: {
149
+ currentLinks: (NavLink | null)[];
150
+ pathname: string;
151
+ }) {
152
+ return currentLinks
153
+ .filter((link): link is NavLink => Boolean(link))
154
+ .filter(
155
+ (link) =>
156
+ (link.href &&
157
+ matchesPath(pathname, link.href, Boolean(link.children?.length))) ||
158
+ link.aliases?.some((alias) =>
159
+ matchesPath(pathname, alias, Boolean(link.children?.length))
160
+ )
161
+ )
162
+ .sort((a, b) => (b.href?.length || 0) - (a.href?.length || 0));
163
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ act,
3
+ cleanup,
4
+ fireEvent,
5
+ render,
6
+ renderHook,
7
+ screen,
8
+ } from '@testing-library/react';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { DropdownMenuItem } from '../dropdown-menu';
11
+ import type { NavLink } from './navigation';
12
+ import { SatelliteFooterActions } from './satellite-footer-actions';
13
+ import { SidebarNavigation } from './satellite-navigation';
14
+ import { SatelliteShell } from './satellite-shell';
15
+ import { SatelliteUserMenu } from './satellite-user-menu';
16
+ import { useSatelliteShell } from './use-satellite-shell';
17
+
18
+ afterEach(cleanup);
19
+
20
+ describe('framework-independent satellite shell', () => {
21
+ it('renders native links, the app launcher, content and account slots without Next providers', () => {
22
+ const openApps = vi.fn();
23
+ const toggle = vi.fn();
24
+ render(
25
+ <SatelliteShell
26
+ isCollapsed={false}
27
+ setIsCollapsed={toggle}
28
+ hideSizeToggle
29
+ brand={{
30
+ appName: 'Colab',
31
+ appHref: '/',
32
+ centralHref: 'https://tuturuuu.com',
33
+ logo: <span>Logo</span>,
34
+ launcherLabel: 'Apps',
35
+ onAppClick: openApps,
36
+ }}
37
+ homeLabel="Home"
38
+ sidebarLabels={{ open: 'Expand', close: 'Collapse' }}
39
+ sidebarContent={
40
+ <SidebarNavigation
41
+ links={[
42
+ { title: 'Workshop', href: '/room' },
43
+ null,
44
+ { title: 'Guide', href: '/guide' },
45
+ ]}
46
+ isCollapsed={false}
47
+ renderLink={(link) => <a href={link.href}>{link.title}</a>}
48
+ />
49
+ }
50
+ actions={<span>Account settings</span>}
51
+ userPopover={<span>Avatar</span>}
52
+ >
53
+ <h1>Team workshop</h1>
54
+ </SatelliteShell>
55
+ );
56
+ expect(screen.getByRole('main').textContent).toContain('Team workshop');
57
+ expect(
58
+ screen.getByRole('link', { name: 'Workshop' }).getAttribute('href')
59
+ ).toBe('/room');
60
+ expect(screen.getByText('Account settings')).toBeTruthy();
61
+ fireEvent.click(screen.getAllByRole('button', { name: 'Apps' })[0]!);
62
+ expect(openApps).toHaveBeenCalledOnce();
63
+ });
64
+
65
+ it('preserves route-driven submenus, back navigation and collapse persistence', () => {
66
+ const persistCollapsed = vi.fn();
67
+ const links: NavLink[] = [
68
+ {
69
+ title: 'Workspace',
70
+ children: [
71
+ { title: 'Tasks', href: '/tasks' },
72
+ { title: 'Calendar', href: '/calendar' },
73
+ ],
74
+ },
75
+ ];
76
+ const { result, rerender } = renderHook(
77
+ ({ pathname }) =>
78
+ useSatelliteShell({
79
+ pathname,
80
+ links,
81
+ backLabel: 'Back',
82
+ persistCollapsed,
83
+ }),
84
+ { initialProps: { pathname: '/tasks' } }
85
+ );
86
+ expect(result.current.navState.titleHistory).toEqual(['Workspace']);
87
+ act(() => result.current.backButton.onClick?.());
88
+ expect(result.current.navState.history).toEqual([]);
89
+ act(() => result.current.handleToggle());
90
+ expect(result.current.isCollapsed).toBe(true);
91
+ expect(persistCollapsed).toHaveBeenCalledWith(true);
92
+ rerender({ pathname: '/calendar' });
93
+ expect(result.current.navState.titleHistory).toEqual(['Workspace']);
94
+ });
95
+
96
+ it('preserves hidden and hover behavior while respecting open dialogs', () => {
97
+ const handleBehaviorChange = vi.fn();
98
+ const links: NavLink[] = [];
99
+ const { result, rerender } = renderHook(
100
+ ({ behavior }: { behavior: 'hidden' | 'hover' }) =>
101
+ useSatelliteShell({
102
+ pathname: '/',
103
+ links,
104
+ backLabel: 'Back',
105
+ behavior,
106
+ handleBehaviorChange,
107
+ }),
108
+ { initialProps: { behavior: 'hidden' } }
109
+ );
110
+ expect(result.current.isCollapsed).toBe(true);
111
+ act(() => result.current.handleToggle());
112
+ expect(handleBehaviorChange).toHaveBeenCalledWith('collapsed');
113
+ rerender({ behavior: 'hover' });
114
+ act(() => result.current.onMouseEnter?.());
115
+ expect(result.current.isCollapsed).toBe(false);
116
+ const { unmount } = render(
117
+ <div role="dialog" data-state="open">
118
+ Settings
119
+ </div>
120
+ );
121
+ act(() => result.current.onMouseLeave?.());
122
+ expect(result.current.isCollapsed).toBe(false);
123
+ unmount();
124
+ act(() => result.current.onMouseLeave?.());
125
+ expect(result.current.isCollapsed).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe('shared account chrome', () => {
130
+ it('exposes the signed-in identity and adapter actions in the profile menu', () => {
131
+ render(
132
+ <SatelliteUserMenu
133
+ name="Ngọc Nguyễn"
134
+ email="ngoc@example.com"
135
+ label="Account"
136
+ online
137
+ >
138
+ <DropdownMenuItem asChild>
139
+ <a href="/profile">My profile</a>
140
+ </DropdownMenuItem>
141
+ </SatelliteUserMenu>
142
+ );
143
+ const trigger = screen.getByRole('button', { name: 'Account' });
144
+ expect(trigger.textContent).toContain('Ngọc Nguyễn');
145
+ expect(trigger.textContent).toContain('ngoc@example.com');
146
+ fireEvent.keyDown(trigger, { key: 'Enter' });
147
+ expect(
148
+ screen.getByRole('menuitem', { name: 'My profile' }).getAttribute('href')
149
+ ).toBe('/profile');
150
+ });
151
+ it('keeps collapsed feedback and community actions accessible', () => {
152
+ const feedback = vi.fn();
153
+ render(
154
+ <SatelliteFooterActions
155
+ wsId=""
156
+ isCollapsed
157
+ showUpgrade={false}
158
+ labels={{ upgrade: 'Upgrade', feedback: 'Feedback' }}
159
+ discordHref="https://discord.gg/example"
160
+ onFeedback={feedback}
161
+ />
162
+ );
163
+ fireEvent.click(screen.getByRole('button', { name: 'Feedback' }));
164
+ expect(feedback).toHaveBeenCalledOnce();
165
+ expect(
166
+ screen.getByRole('link', { name: 'Discord' }).getAttribute('href')
167
+ ).toBe('https://discord.gg/example');
168
+ });
169
+ });
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import type { ComponentProps } from 'react';
4
+ import {
5
+ SatelliteBrand,
6
+ type SatelliteBrandProps,
7
+ ShellAnchor,
8
+ } from './satellite-brand';
9
+ import { Structure } from './structure';
10
+
11
+ export interface SatelliteShellProps
12
+ extends Omit<
13
+ ComponentProps<typeof Structure>,
14
+ 'sidebarHeader' | 'mobileHeader'
15
+ > {
16
+ brand: SatelliteBrandProps;
17
+ homeLabel: string;
18
+ collapsedLogo?: SatelliteBrandProps['logo'];
19
+ mobileBrandActions?: SatelliteBrandProps['actions'];
20
+ }
21
+
22
+ /** Actual satellite chrome. Framework adapters supply routing, auth and workspace services. */
23
+ export function SatelliteShell({
24
+ brand,
25
+ homeLabel,
26
+ collapsedLogo,
27
+ mobileBrandActions,
28
+ ...props
29
+ }: SatelliteShellProps) {
30
+ const Link = brand.LinkComponent ?? ShellAnchor;
31
+ return (
32
+ <Structure
33
+ {...props}
34
+ sidebarHeader={
35
+ props.isCollapsed ? (
36
+ <Link
37
+ aria-label={homeLabel}
38
+ className="flex flex-none items-center justify-center"
39
+ href={brand.centralHref}
40
+ >
41
+ {collapsedLogo ?? brand.logo}
42
+ </Link>
43
+ ) : (
44
+ <SatelliteBrand {...brand} />
45
+ )
46
+ }
47
+ mobileHeader={<SatelliteBrand {...brand} actions={mobileBrandActions} />}
48
+ />
49
+ );
50
+ }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { Check, Monitor, Moon, Sun } from '@tuturuuu/icons';
4
+ import { useTheme } from 'next-themes';
5
+ import { DropdownMenuItem, DropdownMenuSeparator } from '../dropdown-menu';
6
+
7
+ export function SatelliteThemeDropdownItems({
8
+ t,
9
+ }: {
10
+ t: (key: 'dark' | 'light' | 'system') => string;
11
+ }) {
12
+ const { theme, systemTheme, setTheme } = useTheme();
13
+
14
+ const isSystem = theme === 'system' || theme === null;
15
+
16
+ const primaryTheme = theme?.split('-')?.[0] as
17
+ | 'light'
18
+ | 'dark'
19
+ | 'system'
20
+ | undefined;
21
+
22
+ const updateTheme = ({
23
+ primary = primaryTheme,
24
+ }: {
25
+ primary?: 'light' | 'dark' | 'system';
26
+ secondary?:
27
+ | 'pink'
28
+ | 'purple'
29
+ | 'yellow'
30
+ | 'orange'
31
+ | 'green'
32
+ | 'blue'
33
+ | null;
34
+ }) => {
35
+ let theme = '';
36
+
37
+ if (primary) theme += primary === 'system' ? systemTheme : primary;
38
+
39
+ // remove leading dash
40
+ if (theme.startsWith('-')) theme = theme.slice(1);
41
+
42
+ setTheme(theme);
43
+ };
44
+
45
+ return (
46
+ <>
47
+ <DropdownMenuItem
48
+ className="cursor-pointer"
49
+ onClick={() => updateTheme({ primary: 'light' })}
50
+ disabled={primaryTheme === 'light'}
51
+ >
52
+ {primaryTheme === 'light' ? (
53
+ <Check className="h-4 w-4 text-dynamic-yellow" />
54
+ ) : (
55
+ <Sun className="h-4 w-4 text-dynamic-yellow" />
56
+ )}
57
+
58
+ {t('light')}
59
+ </DropdownMenuItem>
60
+
61
+ <DropdownMenuItem
62
+ className="cursor-pointer"
63
+ onClick={() => updateTheme({ primary: 'dark' })}
64
+ disabled={primaryTheme === 'dark'}
65
+ >
66
+ {primaryTheme === 'dark' ? (
67
+ <Check className="h-4 w-4 text-dynamic-purple" />
68
+ ) : (
69
+ <Moon className="h-4 w-4 text-dynamic-purple" />
70
+ )}
71
+
72
+ {t('dark')}
73
+ </DropdownMenuItem>
74
+
75
+ <DropdownMenuSeparator />
76
+
77
+ <DropdownMenuItem
78
+ className="cursor-pointer"
79
+ onClick={() => setTheme('system')}
80
+ disabled={isSystem}
81
+ >
82
+ {isSystem ? (
83
+ <Check className="h-4 w-4" />
84
+ ) : (
85
+ <Monitor className="h-4 w-4" />
86
+ )}
87
+
88
+ {t('system')}
89
+ </DropdownMenuItem>
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertTriangle,
5
+ Check,
6
+ ExternalLink,
7
+ Globe,
8
+ LogOut,
9
+ Palette,
10
+ PanelLeft,
11
+ PanelLeftClose,
12
+ PanelLeftOpen,
13
+ SquareMousePointer,
14
+ } from '@tuturuuu/icons';
15
+ import {
16
+ DropdownMenuGroup,
17
+ DropdownMenuItem,
18
+ DropdownMenuPortal,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuSub,
21
+ DropdownMenuSubContent,
22
+ DropdownMenuSubTrigger,
23
+ } from '@tuturuuu/ui/dropdown-menu';
24
+ import type { ReactNode } from 'react';
25
+
26
+ export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover' | 'hidden';
27
+
28
+ /** One menu definition for the Next.js satellite and framework adapters. */
29
+ export function SatelliteUserMenuItems({
30
+ centralUrl,
31
+ t,
32
+ sidebar,
33
+ languageItems,
34
+ themeItems,
35
+ accountItems,
36
+ workspaceSelect,
37
+ signedIn,
38
+ onReport,
39
+ onLogout,
40
+ }: {
41
+ centralUrl: string;
42
+ t: (
43
+ key:
44
+ | 'common.collapsed'
45
+ | 'common.dashboard'
46
+ | 'common.expand_on_hover'
47
+ | 'common.expanded'
48
+ | 'common.hidden'
49
+ | 'common.language'
50
+ | 'common.logout'
51
+ | 'common.report-problem'
52
+ | 'common.sidebar'
53
+ | 'common.theme'
54
+ ) => string;
55
+ sidebar?: {
56
+ behavior: SidebarBehavior;
57
+ handleBehaviorChange: (value: SidebarBehavior) => void;
58
+ } | null;
59
+ languageItems: ReactNode;
60
+ themeItems: ReactNode;
61
+ accountItems: ReactNode;
62
+ workspaceSelect?: ReactNode;
63
+ signedIn: boolean;
64
+ onReport: () => void;
65
+ onLogout: () => void;
66
+ }) {
67
+ return (
68
+ <>
69
+ {workspaceSelect ? (
70
+ <>
71
+ <div className="w-full p-1 [&_[data-slot=popover-trigger]]:h-9">
72
+ {workspaceSelect}
73
+ </div>
74
+ <DropdownMenuSeparator />
75
+ </>
76
+ ) : null}
77
+ <DropdownMenuGroup>
78
+ <DropdownMenuItem asChild>
79
+ <a
80
+ href={centralUrl}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="cursor-pointer"
84
+ >
85
+ <ExternalLink className="h-4 w-4 text-dynamic-green" />
86
+ <span>{t('common.dashboard')}</span>
87
+ </a>
88
+ </DropdownMenuItem>
89
+ </DropdownMenuGroup>
90
+ <DropdownMenuSeparator />
91
+ <DropdownMenuGroup>
92
+ {sidebar && (
93
+ <DropdownMenuSub>
94
+ <DropdownMenuSubTrigger className="hidden md:flex">
95
+ <PanelLeft className="h-4 w-4 text-dynamic-purple" />
96
+ <span>{t('common.sidebar')}</span>
97
+ </DropdownMenuSubTrigger>
98
+ <DropdownMenuPortal>
99
+ <DropdownMenuSubContent sideOffset={4}>
100
+ <DropdownMenuItem
101
+ onClick={() => sidebar.handleBehaviorChange('expanded')}
102
+ disabled={sidebar.behavior === 'expanded'}
103
+ >
104
+ <PanelLeftOpen className="h-4 w-4 text-dynamic-purple" />
105
+ <span>{t('common.expanded')}</span>
106
+ {sidebar.behavior === 'expanded' && (
107
+ <Check className="ml-auto h-4 w-4" />
108
+ )}
109
+ </DropdownMenuItem>
110
+ <DropdownMenuItem
111
+ onClick={() => sidebar.handleBehaviorChange('collapsed')}
112
+ disabled={sidebar.behavior === 'collapsed'}
113
+ >
114
+ <PanelLeftClose className="h-4 w-4 text-dynamic-purple" />
115
+ <span>{t('common.collapsed')}</span>
116
+ {sidebar.behavior === 'collapsed' && (
117
+ <Check className="ml-auto h-4 w-4" />
118
+ )}
119
+ </DropdownMenuItem>
120
+ <DropdownMenuItem
121
+ onClick={() => sidebar.handleBehaviorChange('hover')}
122
+ disabled={sidebar.behavior === 'hover'}
123
+ >
124
+ <SquareMousePointer className="h-4 w-4 text-dynamic-purple" />
125
+ <span>{t('common.expand_on_hover')}</span>
126
+ {sidebar.behavior === 'hover' && (
127
+ <Check className="ml-auto h-4 w-4" />
128
+ )}
129
+ </DropdownMenuItem>
130
+ <DropdownMenuItem
131
+ onClick={() => sidebar.handleBehaviorChange('hidden')}
132
+ disabled={sidebar.behavior === 'hidden'}
133
+ >
134
+ <PanelLeft className="h-4 w-4 text-dynamic-purple" />
135
+ <span>{t('common.hidden')}</span>
136
+ {sidebar.behavior === 'hidden' && (
137
+ <Check className="ml-auto h-4 w-4" />
138
+ )}
139
+ </DropdownMenuItem>
140
+ </DropdownMenuSubContent>
141
+ </DropdownMenuPortal>
142
+ </DropdownMenuSub>
143
+ )}
144
+ <DropdownMenuSub>
145
+ <DropdownMenuSubTrigger>
146
+ <Globe className="h-4 w-4 text-dynamic-indigo" />
147
+ <span>{t('common.language')}</span>
148
+ </DropdownMenuSubTrigger>
149
+ <DropdownMenuPortal>
150
+ <DropdownMenuSubContent sideOffset={4}>
151
+ {languageItems}
152
+ </DropdownMenuSubContent>
153
+ </DropdownMenuPortal>
154
+ </DropdownMenuSub>
155
+ <DropdownMenuSub>
156
+ <DropdownMenuSubTrigger>
157
+ <Palette className="h-4 w-4 text-dynamic-cyan" />
158
+ <span>{t('common.theme')}</span>
159
+ </DropdownMenuSubTrigger>
160
+ <DropdownMenuPortal>
161
+ <DropdownMenuSubContent sideOffset={4}>
162
+ {themeItems}
163
+ </DropdownMenuSubContent>
164
+ </DropdownMenuPortal>
165
+ </DropdownMenuSub>
166
+ <DropdownMenuItem
167
+ className="cursor-pointer"
168
+ onClick={(e) => {
169
+ e.preventDefault();
170
+ onReport();
171
+ }}
172
+ >
173
+ <AlertTriangle className="h-4 w-4 text-dynamic-yellow" />
174
+ <span>{t('common.report-problem')}</span>
175
+ </DropdownMenuItem>
176
+ </DropdownMenuGroup>
177
+ {signedIn && (
178
+ <>
179
+ <DropdownMenuSeparator />
180
+ <DropdownMenuGroup>{accountItems}</DropdownMenuGroup>
181
+ </>
182
+ )}
183
+ <DropdownMenuSeparator />
184
+ <DropdownMenuItem onClick={onLogout} className="cursor-pointer">
185
+ <LogOut className="h-4 w-4 text-dynamic-red" />
186
+ <span>{t('common.logout')}</span>
187
+ </DropdownMenuItem>
188
+ </>
189
+ );
190
+ }