@xenide-io/the-old-ui-theme 0.3.2 → 0.4.3
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/chunk-54ZR4VL5.mjs +4641 -0
- package/dist/chunk-G53YLHVE.mjs +1023 -0
- package/dist/{index-BgG8Homu.d.mts → index-B5NOyoKS.d.mts} +100 -100
- package/dist/{index-BgG8Homu.d.ts → index-B5NOyoKS.d.ts} +100 -100
- package/dist/index.d.mts +9 -145
- package/dist/index.d.ts +9 -145
- package/dist/index.js +653 -1111
- package/dist/index.mjs +86 -342
- package/dist/suite.d.mts +760 -0
- package/dist/suite.d.ts +760 -0
- package/dist/suite.js +2800 -0
- package/dist/suite.mjs +2441 -0
- package/dist/ui.d.mts +1 -2
- package/dist/ui.d.ts +1 -2
- package/dist/ui.js +14 -10
- package/dist/ui.mjs +13 -11
- package/package.json +43 -24
- package/src/components/icons/icons.tsx +0 -2
- package/src/components/icons/index.ts +0 -3
- package/src/components/sections/IconShowcase.tsx +1 -110
- package/src/components/sections/NewComponentsShowcase.tsx +4 -4
- package/src/components/sections/SidebarShowcase.tsx +3 -3
- package/src/components/sections/SuiteShowcase.tsx +669 -0
- package/src/components/ui/ComponentDocs.tsx +2 -2
- package/src/components/ui/ContextMenu.tsx +1 -1
- package/src/components/ui/Menubar.tsx +1 -1
- package/src/components/ui/Sidebar.tsx +10 -8
- package/src/styles/suite-skin.css +255 -0
- package/src/suite/components/ai-panel.tsx +202 -0
- package/src/suite/components/app-switcher.tsx +196 -0
- package/src/suite/components/command-palette-host.tsx +62 -0
- package/src/suite/components/deferred-chrome.tsx +12 -0
- package/src/suite/components/suite-app-layout.tsx +69 -0
- package/src/suite/components/suite-bottom-nav.tsx +113 -0
- package/src/suite/components/suite-layout.tsx +285 -0
- package/src/suite/components/suite-mobile-drawer.tsx +150 -0
- package/src/suite/components/suite-mobile-header.tsx +71 -0
- package/src/suite/components/suite-notification-bell.tsx +227 -0
- package/src/suite/components/suite-settings-mobile-nav.tsx +83 -0
- package/src/suite/components/suite-sidebar.tsx +198 -0
- package/src/suite/components/suite-skeleton.tsx +191 -0
- package/src/suite/components/suite-user-menu.tsx +109 -0
- package/src/suite/components/theme-provider.tsx +174 -0
- package/src/suite/components/today-calibrating.tsx +95 -0
- package/src/suite/components/today-page-frame.tsx +28 -0
- package/src/suite/components/today-ui.tsx +370 -0
- package/src/suite/icons/app-accents.ts +75 -0
- package/src/suite/icons/glyph-parts.tsx +67 -0
- package/src/suite/icons/glyphs.ts +203 -0
- package/src/suite/icons/index.ts +12 -0
- package/src/suite/icons/suite-app-icon.tsx +68 -0
- package/src/suite/icons/suite-icon.tsx +93 -0
- package/src/suite/index.ts +108 -0
- package/src/suite/lib/apps.ts +75 -0
- package/src/suite/lib/cn.ts +6 -0
- package/src/suite/lib/injected.ts +54 -0
- package/src/suite/lib/motion.tsx +48 -0
- package/src/suite/lib/use-idle-mount.ts +25 -0
- package/dist/chunk-5HSOBJ4F.mjs +0 -5968
- package/src/components/icons/lemon-brand-icons.tsx +0 -16
- package/src/components/icons/lemon-category-icons.tsx +0 -72
- package/src/components/icons/lemon-icons.tsx +0 -57
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Bell } from 'iconoir-react';
|
|
6
|
+
|
|
7
|
+
import type { SuiteDropdownMenuComponent } from '../lib/injected';
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
/** Cross-app (suite) notification from `/api/notifications/` — shared across ShellStack apps. */
|
|
12
|
+
export interface SuiteNotification {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
body: string;
|
|
16
|
+
href: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
source_app: string;
|
|
19
|
+
read_at: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
workspace: string | null;
|
|
22
|
+
organisation: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SuiteNotificationsResponse {
|
|
26
|
+
notifications: SuiteNotification[];
|
|
27
|
+
unread_count: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function timeAgo(iso: string): string {
|
|
31
|
+
const then = new Date(iso).getTime();
|
|
32
|
+
if (Number.isNaN(then)) return '';
|
|
33
|
+
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
34
|
+
if (secs < 60) return 'just now';
|
|
35
|
+
const mins = Math.round(secs / 60);
|
|
36
|
+
if (mins < 60) return `${mins}m ago`;
|
|
37
|
+
const hrs = Math.round(mins / 60);
|
|
38
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
39
|
+
return `${Math.round(hrs / 24)}d ago`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SuiteNotificationBell({
|
|
43
|
+
fetchNotifications,
|
|
44
|
+
markRead,
|
|
45
|
+
markAllRead,
|
|
46
|
+
dropdownMenu: DropdownMenu,
|
|
47
|
+
cacheKey = 'suite-notifications-cache',
|
|
48
|
+
}: {
|
|
49
|
+
fetchNotifications: () => Promise<SuiteNotificationsResponse>;
|
|
50
|
+
markRead: (id: string) => Promise<unknown>;
|
|
51
|
+
markAllRead: () => Promise<unknown>;
|
|
52
|
+
dropdownMenu: SuiteDropdownMenuComponent;
|
|
53
|
+
/** sessionStorage key for stale-while-revalidate; `null` disables caching. */
|
|
54
|
+
cacheKey?: string | null;
|
|
55
|
+
}) {
|
|
56
|
+
const router = useRouter();
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const [items, setItems] = useState<SuiteNotification[]>([]);
|
|
59
|
+
const [unread, setUnread] = useState(0);
|
|
60
|
+
|
|
61
|
+
const load = useCallback(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const data = await fetchNotifications();
|
|
64
|
+
setItems(data.notifications);
|
|
65
|
+
setUnread(data.unread_count);
|
|
66
|
+
// Stale-while-revalidate: keep last inbox for instant paint next visit.
|
|
67
|
+
if (cacheKey) {
|
|
68
|
+
try {
|
|
69
|
+
sessionStorage.setItem(
|
|
70
|
+
cacheKey,
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
notifications: data.notifications,
|
|
73
|
+
unread_count: data.unread_count,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore quota / private mode
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Non-critical chrome — never surface bell errors to the user.
|
|
82
|
+
}
|
|
83
|
+
}, [fetchNotifications, cacheKey]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (cacheKey) {
|
|
87
|
+
queueMicrotask(() => {
|
|
88
|
+
try {
|
|
89
|
+
const raw = sessionStorage.getItem(cacheKey);
|
|
90
|
+
if (raw) {
|
|
91
|
+
const cached = JSON.parse(raw) as {
|
|
92
|
+
notifications?: SuiteNotification[];
|
|
93
|
+
unread_count?: number;
|
|
94
|
+
};
|
|
95
|
+
if (Array.isArray(cached.notifications)) {
|
|
96
|
+
setItems(cached.notifications);
|
|
97
|
+
setUnread(cached.unread_count ?? 0);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const kick = window.setTimeout(() => void load(), 0);
|
|
106
|
+
const id = window.setInterval(() => void load(), POLL_MS);
|
|
107
|
+
return () => {
|
|
108
|
+
window.clearTimeout(kick);
|
|
109
|
+
window.clearInterval(id);
|
|
110
|
+
};
|
|
111
|
+
}, [load, cacheKey]);
|
|
112
|
+
|
|
113
|
+
const onOpenChange = useCallback(
|
|
114
|
+
(next: boolean) => {
|
|
115
|
+
setOpen(next);
|
|
116
|
+
if (next) void load();
|
|
117
|
+
},
|
|
118
|
+
[load],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const openItem = useCallback(
|
|
122
|
+
async (n: SuiteNotification) => {
|
|
123
|
+
setOpen(false);
|
|
124
|
+
if (!n.read_at) {
|
|
125
|
+
setItems((prev) =>
|
|
126
|
+
prev.map((i) =>
|
|
127
|
+
i.id === n.id ? { ...i, read_at: new Date().toISOString() } : i,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
setUnread((u) => Math.max(0, u - 1));
|
|
131
|
+
try {
|
|
132
|
+
await markRead(n.id);
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!n.href) return;
|
|
138
|
+
if (/^https?:\/\//i.test(n.href)) window.location.assign(n.href);
|
|
139
|
+
else router.push(n.href);
|
|
140
|
+
},
|
|
141
|
+
[router, markRead],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const markAll = useCallback(async () => {
|
|
145
|
+
setItems((prev) =>
|
|
146
|
+
prev.map((i) => ({
|
|
147
|
+
...i,
|
|
148
|
+
read_at: i.read_at ?? new Date().toISOString(),
|
|
149
|
+
})),
|
|
150
|
+
);
|
|
151
|
+
setUnread(0);
|
|
152
|
+
try {
|
|
153
|
+
await markAllRead();
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
}, [markAllRead]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<DropdownMenu
|
|
161
|
+
aria-label="Notifications"
|
|
162
|
+
align="end"
|
|
163
|
+
panelClassName="w-80 overflow-hidden p-0"
|
|
164
|
+
open={open}
|
|
165
|
+
onOpenChange={onOpenChange}
|
|
166
|
+
trigger={
|
|
167
|
+
<span className="relative inline-flex h-9 w-9 items-center justify-center rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
|
|
168
|
+
<Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
|
|
169
|
+
{unread > 0 ? (
|
|
170
|
+
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[10px] font-semibold leading-none text-white ring-2 ring-ph-surface">
|
|
171
|
+
{unread > 9 ? '9+' : unread}
|
|
172
|
+
</span>
|
|
173
|
+
) : null}
|
|
174
|
+
</span>
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
<div className="flex items-center justify-between border-b border-ph-border px-3 py-2">
|
|
178
|
+
<span className="text-sm font-semibold text-ph-ink">Notifications</span>
|
|
179
|
+
{unread > 0 ? (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={markAll}
|
|
183
|
+
className="text-xs font-medium text-ph-brand hover:underline"
|
|
184
|
+
>
|
|
185
|
+
Mark all read
|
|
186
|
+
</button>
|
|
187
|
+
) : null}
|
|
188
|
+
</div>
|
|
189
|
+
<div className="max-h-80 overflow-y-auto py-1">
|
|
190
|
+
{items.length === 0 ? (
|
|
191
|
+
<p className="px-3 py-6 text-center text-sm text-ph-mutedtext">
|
|
192
|
+
You're all caught up.
|
|
193
|
+
</p>
|
|
194
|
+
) : (
|
|
195
|
+
items.map((n) => (
|
|
196
|
+
<button
|
|
197
|
+
key={n.id}
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => void openItem(n)}
|
|
200
|
+
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
|
|
201
|
+
>
|
|
202
|
+
<span className="flex items-center gap-2">
|
|
203
|
+
{!n.read_at ? (
|
|
204
|
+
<span
|
|
205
|
+
className="h-1.5 w-1.5 shrink-0 rounded-full bg-ph-brand"
|
|
206
|
+
aria-hidden
|
|
207
|
+
/>
|
|
208
|
+
) : null}
|
|
209
|
+
<span className="truncate text-sm font-medium text-ph-ink">
|
|
210
|
+
{n.title}
|
|
211
|
+
</span>
|
|
212
|
+
</span>
|
|
213
|
+
{n.body ? (
|
|
214
|
+
<span className="line-clamp-2 text-xs text-ph-mutedtext">
|
|
215
|
+
{n.body}
|
|
216
|
+
</span>
|
|
217
|
+
) : null}
|
|
218
|
+
<span className="text-[11px] uppercase tracking-wide text-ph-subtle">
|
|
219
|
+
{n.source_app} · {timeAgo(n.created_at)}
|
|
220
|
+
</span>
|
|
221
|
+
</button>
|
|
222
|
+
))
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</DropdownMenu>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { isValidElement, type ComponentType, type ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export interface SuiteSettingsNavItem {
|
|
7
|
+
label: string;
|
|
8
|
+
href: string;
|
|
9
|
+
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> | ReactNode;
|
|
10
|
+
testId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SuiteSettingsMobileNavProps {
|
|
14
|
+
items: SuiteSettingsNavItem[];
|
|
15
|
+
activeHref: string;
|
|
16
|
+
onSelect: (href: string) => void;
|
|
17
|
+
dataTest?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Horizontal scrollable settings nav for mobile.
|
|
22
|
+
* Replaces a native <select> with icon+label pills that are easier to scan,
|
|
23
|
+
* meet 44px touch targets, and preserve visual hierarchy (Law of UX: Hick's Law,
|
|
24
|
+
* Recognition over Recall).
|
|
25
|
+
*/
|
|
26
|
+
export function SuiteSettingsMobileNav({
|
|
27
|
+
items,
|
|
28
|
+
activeHref,
|
|
29
|
+
onSelect,
|
|
30
|
+
dataTest,
|
|
31
|
+
}: SuiteSettingsMobileNavProps) {
|
|
32
|
+
return (
|
|
33
|
+
<nav
|
|
34
|
+
data-test={dataTest}
|
|
35
|
+
aria-label="Settings sections"
|
|
36
|
+
className="-mx-4 px-4 lg:hidden"
|
|
37
|
+
>
|
|
38
|
+
<div className="flex snap-x gap-2 overflow-x-auto pb-3 pt-1 scrollbar-hide [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
39
|
+
{items.map((item) => {
|
|
40
|
+
const Icon = item.icon as ComponentType<{
|
|
41
|
+
className?: string;
|
|
42
|
+
'aria-hidden'?: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
const active =
|
|
45
|
+
activeHref === item.href || activeHref.startsWith(`${item.href}/`);
|
|
46
|
+
const iconNode = isValidElement(item.icon) ? (
|
|
47
|
+
item.icon
|
|
48
|
+
) : (
|
|
49
|
+
<Icon
|
|
50
|
+
className={cn(
|
|
51
|
+
'h-4 w-4 shrink-0 transition',
|
|
52
|
+
active ? 'text-ph-surface' : 'text-ph-mutedtext',
|
|
53
|
+
)}
|
|
54
|
+
aria-hidden
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
key={item.href}
|
|
60
|
+
type="button"
|
|
61
|
+
data-test={item.testId}
|
|
62
|
+
onClick={() => onSelect(item.href)}
|
|
63
|
+
aria-current={active ? 'page' : undefined}
|
|
64
|
+
className={cn(
|
|
65
|
+
'group relative flex shrink-0 snap-start items-center gap-2 rounded-full px-3.5 py-2.5 text-sm font-medium transition',
|
|
66
|
+
'min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand',
|
|
67
|
+
active
|
|
68
|
+
? 'bg-ph-ink text-ph-surface shadow-sm'
|
|
69
|
+
: 'bg-ph-surface text-ph-subtle ring-1 ring-ph-border hover:bg-ph-muted hover:text-ph-ink',
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{iconNode}
|
|
73
|
+
<span className="whitespace-nowrap">{item.label}</span>
|
|
74
|
+
{active ? (
|
|
75
|
+
<span className="absolute inset-x-0 -bottom-3 h-0.5 rounded-full bg-ph-ink lg:hidden" />
|
|
76
|
+
) : null}
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
</nav>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn';
|
|
7
|
+
import { Tooltip } from '../../components/ui/Tooltip';
|
|
8
|
+
import type { SuiteNavIcon } from './suite-bottom-nav';
|
|
9
|
+
|
|
10
|
+
type CollapsedNode = ReactNode | ((collapsed: boolean) => ReactNode);
|
|
11
|
+
|
|
12
|
+
export interface SuiteSidebarNavItem {
|
|
13
|
+
href: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon: SuiteNavIcon;
|
|
16
|
+
active?: boolean;
|
|
17
|
+
onClick?: () => void;
|
|
18
|
+
badge?: ReactNode;
|
|
19
|
+
/** Optional colour class for the nav icon. */
|
|
20
|
+
iconClassName?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SuiteSidebarProps {
|
|
24
|
+
/** Optional custom app switcher node, or a function of collapsed state. */
|
|
25
|
+
appSwitcher?: CollapsedNode;
|
|
26
|
+
/** Workspace/org/project switcher rendered below the app switcher. */
|
|
27
|
+
contextSwitcher: CollapsedNode;
|
|
28
|
+
navItems: SuiteSidebarNavItem[];
|
|
29
|
+
/** Optional secondary nav / tree rendered below primary nav. */
|
|
30
|
+
secondaryNav?: ReactNode;
|
|
31
|
+
userMenu: CollapsedNode;
|
|
32
|
+
notificationBell?: CollapsedNode;
|
|
33
|
+
/** Optional extra footer content placed next to the user menu. */
|
|
34
|
+
footerExtras?: CollapsedNode;
|
|
35
|
+
collapsed?: boolean;
|
|
36
|
+
className?: string;
|
|
37
|
+
/** Use the richer surface background instead of canvas. */
|
|
38
|
+
surface?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderNode(node: CollapsedNode | undefined, collapsed: boolean): ReactNode {
|
|
42
|
+
if (typeof node === 'function') return node(collapsed);
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Standardised ShellStack sidebar. Matches the original TurtleTime layout
|
|
48
|
+
* so the chrome feels familiar across all apps:
|
|
49
|
+
* - app switcher + notification bell in a compact header
|
|
50
|
+
* - workspace switcher at the top of the nav body
|
|
51
|
+
* - primary nav items styled like the original NavLink
|
|
52
|
+
* - avatar + optional logout in the footer
|
|
53
|
+
*/
|
|
54
|
+
export function SuiteSidebar({
|
|
55
|
+
appSwitcher,
|
|
56
|
+
contextSwitcher,
|
|
57
|
+
navItems,
|
|
58
|
+
secondaryNav,
|
|
59
|
+
userMenu,
|
|
60
|
+
notificationBell,
|
|
61
|
+
footerExtras,
|
|
62
|
+
collapsed = false,
|
|
63
|
+
className,
|
|
64
|
+
surface = false,
|
|
65
|
+
}: SuiteSidebarProps) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
'flex h-full w-full min-w-0 flex-col text-ph-ink',
|
|
70
|
+
surface ? 'bg-ph-surface' : 'bg-ph-canvas',
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{/* Header */}
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
'flex shrink-0 border-b border-ph-border',
|
|
78
|
+
surface ? 'bg-ph-surface' : 'bg-ph-canvas',
|
|
79
|
+
collapsed
|
|
80
|
+
? 'flex-col items-center gap-1 px-1 py-2'
|
|
81
|
+
: 'h-14 items-center justify-between gap-2 px-3',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<div className={cn('min-w-0', collapsed ? 'shrink-0' : 'flex-1')}>
|
|
85
|
+
{renderNode(appSwitcher, collapsed)}
|
|
86
|
+
</div>
|
|
87
|
+
{collapsed ? null : (
|
|
88
|
+
<div className="shrink-0">{renderNode(notificationBell, collapsed)}</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Nav body */}
|
|
93
|
+
<div
|
|
94
|
+
className={cn(
|
|
95
|
+
'flex min-h-0 flex-1 flex-col overflow-y-auto',
|
|
96
|
+
surface ? 'bg-ph-surface' : 'bg-ph-canvas',
|
|
97
|
+
collapsed ? 'px-1.5 py-2' : 'p-2',
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<nav
|
|
101
|
+
aria-label="Pages"
|
|
102
|
+
className="shrink-0 space-y-0.5"
|
|
103
|
+
>
|
|
104
|
+
<div className={cn('mb-3', collapsed && 'flex justify-center')}>
|
|
105
|
+
{renderNode(contextSwitcher, collapsed)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="space-y-0.5">
|
|
109
|
+
{navItems.map((item) => {
|
|
110
|
+
const Icon = item.icon;
|
|
111
|
+
const active = Boolean(item.active);
|
|
112
|
+
const link = (
|
|
113
|
+
<Link
|
|
114
|
+
key={item.label}
|
|
115
|
+
href={item.href}
|
|
116
|
+
data-test="nav-link"
|
|
117
|
+
aria-current={active ? 'page' : undefined}
|
|
118
|
+
onClick={item.onClick}
|
|
119
|
+
className={cn(
|
|
120
|
+
'group relative flex items-center gap-2.5 rounded-[var(--ph-radius-app)] text-sm font-medium transition-colors',
|
|
121
|
+
active
|
|
122
|
+
? 'bg-ph-muted'
|
|
123
|
+
: 'text-ph-subtle hover:bg-ph-muted hover:text-ph-ink',
|
|
124
|
+
collapsed
|
|
125
|
+
? 'mx-auto size-10 shrink-0 justify-center gap-0 px-0 py-0'
|
|
126
|
+
: 'w-full px-2.5 py-2',
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
<span
|
|
130
|
+
className={cn(
|
|
131
|
+
'relative flex h-4 w-4 shrink-0 items-center justify-center',
|
|
132
|
+
item.iconClassName,
|
|
133
|
+
)}
|
|
134
|
+
aria-hidden
|
|
135
|
+
>
|
|
136
|
+
<Icon
|
|
137
|
+
className="h-full w-full"
|
|
138
|
+
strokeWidth={active ? 2 : 1.75}
|
|
139
|
+
/>
|
|
140
|
+
</span>
|
|
141
|
+
<span
|
|
142
|
+
className={cn(
|
|
143
|
+
'relative truncate',
|
|
144
|
+
active ? 'text-ph-ink' : 'text-ph-subtle group-hover:text-ph-ink',
|
|
145
|
+
collapsed && 'sr-only',
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{item.label}
|
|
149
|
+
</span>
|
|
150
|
+
{!collapsed && item.badge ? (
|
|
151
|
+
<span className="relative ml-auto shrink-0">{item.badge}</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</Link>
|
|
154
|
+
);
|
|
155
|
+
return collapsed ? (
|
|
156
|
+
<Tooltip key={item.label} content={item.label} side="right">
|
|
157
|
+
{link}
|
|
158
|
+
</Tooltip>
|
|
159
|
+
) : (
|
|
160
|
+
link
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
</nav>
|
|
165
|
+
|
|
166
|
+
{secondaryNav ? (
|
|
167
|
+
<div
|
|
168
|
+
className="min-h-0 flex-1"
|
|
169
|
+
data-test="suite-sidebar-secondary-nav"
|
|
170
|
+
>
|
|
171
|
+
{secondaryNav}
|
|
172
|
+
</div>
|
|
173
|
+
) : null}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Footer */}
|
|
177
|
+
<div
|
|
178
|
+
className={cn(
|
|
179
|
+
'shrink-0 border-t border-ph-border',
|
|
180
|
+
surface ? 'bg-ph-surface' : 'bg-ph-canvas',
|
|
181
|
+
collapsed ? 'p-1.5' : 'p-3',
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
className={cn(
|
|
186
|
+
'flex items-center gap-2',
|
|
187
|
+
collapsed && 'justify-center',
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{renderNode(userMenu, collapsed)}
|
|
191
|
+
{!collapsed && footerExtras ? (
|
|
192
|
+
<div className="ml-auto shrink-0">{renderNode(footerExtras, collapsed)}</div>
|
|
193
|
+
) : null}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
interface SuiteSkeletonProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
lines?: number;
|
|
9
|
+
circle?: boolean;
|
|
10
|
+
width?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shared skeleton using the-old-ui-theme `ph-skeleton` shimmer class.
|
|
15
|
+
* The shimmer animation is defined in the theme, so this component is
|
|
16
|
+
* framework-agnostic and works in any app that imports the theme.
|
|
17
|
+
*/
|
|
18
|
+
export function SuiteSkeleton({
|
|
19
|
+
className,
|
|
20
|
+
lines = 1,
|
|
21
|
+
circle,
|
|
22
|
+
width,
|
|
23
|
+
}: SuiteSkeletonProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn('space-y-2', circle ? 'flex flex-col items-center' : '')}
|
|
27
|
+
aria-hidden
|
|
28
|
+
>
|
|
29
|
+
{Array.from({ length: lines }).map((_, index) => (
|
|
30
|
+
<div
|
|
31
|
+
key={index}
|
|
32
|
+
className="animate-[skeleton-stagger_0.6s_ease-out_both]"
|
|
33
|
+
style={{ animationDelay: `${index * 80}ms` }}
|
|
34
|
+
>
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
'ph-skeleton',
|
|
38
|
+
circle ? 'h-10 w-10 rounded-full' : 'h-3',
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
style={
|
|
42
|
+
!circle
|
|
43
|
+
? {
|
|
44
|
+
width:
|
|
45
|
+
width ?? `${70 + (index % 3) * 15}%`,
|
|
46
|
+
}
|
|
47
|
+
: undefined
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SuiteSkeletonCard({
|
|
57
|
+
className,
|
|
58
|
+
rows = 3,
|
|
59
|
+
header = true,
|
|
60
|
+
}: {
|
|
61
|
+
className?: string;
|
|
62
|
+
rows?: number;
|
|
63
|
+
header?: boolean;
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
'ph-skeleton-card animate-[skeleton-stagger_0.5s_ease-out_both] rounded-2xl border border-ph-border/70 bg-ph-surface p-6 shadow-sm',
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
aria-hidden
|
|
72
|
+
>
|
|
73
|
+
{header ? (
|
|
74
|
+
<div
|
|
75
|
+
className="mb-5 flex items-center gap-3 animate-[skeleton-stagger_0.5s_ease-out_both]"
|
|
76
|
+
style={{ animationDelay: '100ms' }}
|
|
77
|
+
>
|
|
78
|
+
<div className="ph-skeleton h-10 w-10 rounded-xl" />
|
|
79
|
+
<div className="flex-1 space-y-2">
|
|
80
|
+
<div className="ph-skeleton h-4 w-1/3" />
|
|
81
|
+
<div className="ph-skeleton h-3 w-1/2" />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
) : null}
|
|
85
|
+
<div className="space-y-3">
|
|
86
|
+
{Array.from({ length: rows }).map((_, index) => (
|
|
87
|
+
<div
|
|
88
|
+
key={index}
|
|
89
|
+
className="animate-[skeleton-stagger_0.5s_ease-out_both]"
|
|
90
|
+
style={{ animationDelay: `${200 + index * 80}ms` }}
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
className="ph-skeleton h-3"
|
|
94
|
+
style={{ width: `${70 + (index % 3) * 15}%` }}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function SuiteSkeletonList({
|
|
104
|
+
className,
|
|
105
|
+
items = 4,
|
|
106
|
+
}: {
|
|
107
|
+
className?: string;
|
|
108
|
+
items?: number;
|
|
109
|
+
}) {
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn('space-y-3', className)} aria-hidden>
|
|
112
|
+
{Array.from({ length: items }).map((_, index) => (
|
|
113
|
+
<div
|
|
114
|
+
key={index}
|
|
115
|
+
className="animate-[skeleton-stagger_0.6s_ease-out_both]"
|
|
116
|
+
style={{ animationDelay: `${index * 100}ms` }}
|
|
117
|
+
>
|
|
118
|
+
<div className="flex items-center gap-3 rounded-xl border border-ph-border/60 bg-ph-surface p-4">
|
|
119
|
+
<div className="ph-skeleton h-10 w-10 rounded-lg" />
|
|
120
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
121
|
+
<div className="ph-skeleton h-3.5 w-3/4" />
|
|
122
|
+
<div className="ph-skeleton h-3 w-1/2" />
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface SuiteEmptyStateProps {
|
|
132
|
+
icon?: ReactNode;
|
|
133
|
+
title: string;
|
|
134
|
+
description?: ReactNode;
|
|
135
|
+
action?: ReactNode;
|
|
136
|
+
className?: string;
|
|
137
|
+
size?: 'sm' | 'md';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Branded empty state with a soft gradient orb and clear action affordance.
|
|
142
|
+
* Uses the app icon when available so empty states feel native to each app.
|
|
143
|
+
*/
|
|
144
|
+
export function SuiteEmptyState({
|
|
145
|
+
icon,
|
|
146
|
+
title,
|
|
147
|
+
description,
|
|
148
|
+
action,
|
|
149
|
+
className,
|
|
150
|
+
size = 'md',
|
|
151
|
+
}: SuiteEmptyStateProps) {
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
'relative overflow-hidden rounded-2xl border border-ph-border/80 bg-ph-surface text-center',
|
|
156
|
+
size === 'sm' ? 'px-5 py-8' : 'px-6 py-12 sm:px-10 sm:py-16',
|
|
157
|
+
className,
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-60" aria-hidden>
|
|
161
|
+
<div className="absolute -left-10 -top-10 h-40 w-40 rounded-full bg-ph-brand/10 blur-3xl" />
|
|
162
|
+
<div className="absolute -bottom-10 -right-10 h-40 w-40 rounded-full bg-ph-accent/10 blur-3xl" />
|
|
163
|
+
</div>
|
|
164
|
+
<div className="relative">
|
|
165
|
+
{icon ? (
|
|
166
|
+
<div className="mx-auto mb-4 inline-flex items-center justify-center rounded-2xl bg-ph-muted p-3 shadow-sm ring-1 ring-black/[0.06]">
|
|
167
|
+
{icon}
|
|
168
|
+
</div>
|
|
169
|
+
) : null}
|
|
170
|
+
<h3
|
|
171
|
+
className={cn(
|
|
172
|
+
'font-semibold text-ph-ink',
|
|
173
|
+
size === 'sm' ? 'text-base' : 'text-lg',
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
{title}
|
|
177
|
+
</h3>
|
|
178
|
+
{description ? (
|
|
179
|
+
<p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-ph-subtle">
|
|
180
|
+
{description}
|
|
181
|
+
</p>
|
|
182
|
+
) : null}
|
|
183
|
+
{action ? (
|
|
184
|
+
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
|
185
|
+
{action}
|
|
186
|
+
</div>
|
|
187
|
+
) : null}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|