@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.
- package/CHANGELOG.md +49 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- 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
|
+
}
|