@djangocfg/layouts 2.1.319 → 2.1.321
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/README.md +41 -102
- package/package.json +27 -18
- package/src/configurator/index.ts +14 -0
- package/src/configurator/private/index.ts +6 -0
- package/src/configurator/private/schema.ts +190 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +46 -1
- package/src/layouts/PrivateLayout/README.md +129 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +1 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +214 -47
- package/src/layouts/PrivateLayout/index.ts +10 -1
- package/src/layouts/_components/PrivateSidebarAccount.tsx +284 -146
- package/src/layouts/_components/SidebarFeatured.tsx +70 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
|
|
3
|
+
* action) opens a popover (DropdownMenu) upward with email, account links, locale +
|
|
4
|
+
* theme controls, and sign-out. Replaces the legacy inline collapsible.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
'use client';
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
|
|
9
10
|
import { Link } from '@djangocfg/ui-core/components';
|
|
10
11
|
import React from 'react';
|
|
11
12
|
|
|
@@ -16,65 +17,47 @@ import {
|
|
|
16
17
|
AvatarFallback,
|
|
17
18
|
AvatarImage,
|
|
18
19
|
Button,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuLabel,
|
|
24
|
+
DropdownMenuSeparator,
|
|
25
|
+
DropdownMenuTrigger,
|
|
22
26
|
} from '@djangocfg/ui-core/components';
|
|
23
|
-
import { cn } from '@djangocfg/ui-core/lib';
|
|
24
|
-
import { useSidebar } from '@djangocfg/ui-
|
|
25
|
-
import {
|
|
27
|
+
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
28
|
+
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
29
|
+
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
26
30
|
|
|
27
31
|
import { useLogout } from '../../hooks';
|
|
28
|
-
import {
|
|
32
|
+
import { LocaleSwitcherDialog } from './locale-switcher';
|
|
33
|
+
import { getLocaleMeta } from './locale-switcher/localeMeta';
|
|
29
34
|
import { useLayoutI18nOptional } from '../AppLayout/LayoutI18nProvider';
|
|
35
|
+
import { LucideIcon as LucideIconRender } from '../../components';
|
|
30
36
|
|
|
31
37
|
import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
|
|
32
38
|
|
|
33
|
-
/** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
|
|
34
|
-
function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
|
|
35
|
-
if (!target || !(target instanceof Element)) return false;
|
|
36
|
-
return Boolean(
|
|
37
|
-
target.closest('[data-radix-popper-content-wrapper]') ||
|
|
38
|
-
target.closest('[data-radix-dropdown-menu-content]') ||
|
|
39
|
-
target.closest('[data-radix-select-content]') ||
|
|
40
|
-
target.closest('[data-radix-popover-content]'),
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
interface PrivateSidebarAccountProps {
|
|
45
40
|
header?: HeaderConfig;
|
|
46
41
|
}
|
|
47
42
|
|
|
43
|
+
interface AccountView {
|
|
44
|
+
source: 'user' | 'dev-fallback';
|
|
45
|
+
displayName: string;
|
|
46
|
+
email: string | null;
|
|
47
|
+
avatarUrl: string;
|
|
48
|
+
plan: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
48
51
|
export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
49
52
|
const { user } = useAuth();
|
|
50
53
|
const handleLogout = useLogout();
|
|
51
54
|
const t = useAppT();
|
|
52
55
|
const layoutI18n = useLayoutI18nOptional();
|
|
53
56
|
const { state, setOpen: setSidebarOpen } = useSidebar();
|
|
54
|
-
const
|
|
55
|
-
const
|
|
57
|
+
const { theme, setTheme } = useThemeContext();
|
|
58
|
+
const [langDialogOpen, setLangDialogOpen] = React.useState(false);
|
|
56
59
|
const narrow = state === 'collapsed';
|
|
57
60
|
|
|
58
|
-
React.useEffect(() => {
|
|
59
|
-
if (state === 'collapsed') setAccountOpen(false);
|
|
60
|
-
}, [state]);
|
|
61
|
-
|
|
62
|
-
React.useEffect(() => {
|
|
63
|
-
if (!accountOpen) return;
|
|
64
|
-
|
|
65
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
66
|
-
const root = accountRootRef.current;
|
|
67
|
-
const target = event.target;
|
|
68
|
-
if (!(target instanceof Node)) return;
|
|
69
|
-
if (root?.contains(target)) return;
|
|
70
|
-
if (isPointerFromRadixOverlay(target)) return;
|
|
71
|
-
setAccountOpen(false);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
document.addEventListener('pointerdown', handlePointerDown);
|
|
75
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
|
76
|
-
}, [accountOpen]);
|
|
77
|
-
|
|
78
61
|
const signOutLabel = t('layouts.profile.signOut');
|
|
79
62
|
|
|
80
63
|
const accountLinks = React.useMemo(() => {
|
|
@@ -82,134 +65,289 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
|
|
|
82
65
|
return header.groups.flatMap((g) => g.items.filter((i) => i.href));
|
|
83
66
|
}, [header?.groups]);
|
|
84
67
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Single source of truth for what the footer renders. When `useAuth` returns
|
|
70
|
+
* a real user we use it; otherwise (in dev only) we fall back to a placeholder
|
|
71
|
+
* so the menu stays reachable for debugging. Production stays strict and the
|
|
72
|
+
* component returns null below.
|
|
73
|
+
*/
|
|
74
|
+
const account = React.useMemo<AccountView>(() => {
|
|
75
|
+
if (user) {
|
|
76
|
+
return {
|
|
77
|
+
source: 'user',
|
|
78
|
+
displayName: user.display_username || user.email || 'User',
|
|
79
|
+
email: user.email ?? null,
|
|
80
|
+
avatarUrl: user.avatar ?? '',
|
|
81
|
+
plan: header?.userPlan ?? null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
source: 'dev-fallback',
|
|
86
|
+
displayName: 'Guest (dev)',
|
|
87
|
+
email: null,
|
|
88
|
+
avatarUrl: '',
|
|
89
|
+
plan: header?.userPlan ?? 'No session',
|
|
90
|
+
};
|
|
91
|
+
}, [user, header?.userPlan]);
|
|
88
92
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const onTriggerInteract = React.useCallback(() => {
|
|
94
|
+
if (narrow) setSidebarOpen(true);
|
|
95
|
+
}, [narrow, setSidebarOpen]);
|
|
96
|
+
|
|
97
|
+
const onSecondaryExpand = React.useCallback(() => {
|
|
98
|
+
setSidebarOpen(true);
|
|
99
|
+
}, [setSidebarOpen]);
|
|
100
|
+
|
|
101
|
+
const onLogoutSelect = React.useCallback((e: Event) => {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
handleLogout();
|
|
104
|
+
}, [handleLogout]);
|
|
105
|
+
|
|
106
|
+
const onLanguageSelect = React.useCallback((e: Event) => {
|
|
107
|
+
// Keep the dropdown closed (default behaviour) but defer dialog mount to
|
|
108
|
+
// the next tick so Radix has time to unmount the dropdown overlay first
|
|
109
|
+
// (avoids the "two open overlays steal focus" bug).
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
setTimeout(() => setLangDialogOpen(true), 0);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const onThemeSelect = React.useCallback((e: Event) => {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
// Cycle: light → dark → system → light
|
|
117
|
+
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
|
|
118
|
+
setTheme(next);
|
|
119
|
+
}, [theme, setTheme]);
|
|
120
|
+
|
|
121
|
+
// Hide entirely in production when there's no user (auth still loading or
|
|
122
|
+
// /me failed and the parent guard hasn't redirected yet). In dev keep a
|
|
123
|
+
// placeholder so the footer + Log out are reachable for debugging.
|
|
124
|
+
// NOTE: this early-return must stay AFTER all hooks above to keep hook order stable.
|
|
125
|
+
if (!user && !isDev) return null;
|
|
126
|
+
|
|
127
|
+
const userInitial = (account.displayName.charAt(0) || '?').toUpperCase();
|
|
128
|
+
const secondary = header?.footerSecondaryAction;
|
|
93
129
|
|
|
94
130
|
const triggerClassName = cn(
|
|
95
|
-
'h-auto
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const chevronClassName = cn(
|
|
99
|
-
'h-4 w-4 shrink-0 text-sidebar-foreground/65 transition-transform duration-200',
|
|
100
|
-
accountOpen && 'rotate-180',
|
|
131
|
+
'group h-auto w-full gap-3 rounded-lg px-2 py-2 text-left',
|
|
132
|
+
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
133
|
+
narrow ? 'justify-center px-0 py-1.5' : 'min-h-14',
|
|
101
134
|
);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
|
|
136
|
+
const secondaryButton = secondary && !narrow ? (
|
|
137
|
+
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
138
|
+
) : null;
|
|
139
|
+
|
|
140
|
+
const dropdownContentClass = cn(
|
|
141
|
+
'p-1.5',
|
|
142
|
+
narrow ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
|
|
105
143
|
);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
144
|
+
const dropdownSide: 'top' | 'right' = narrow ? 'right' : 'top';
|
|
145
|
+
const avatarClass = cn(
|
|
146
|
+
'h-9 w-9 shrink-0 border border-transparent transition-colors',
|
|
147
|
+
'group-hover:border-sidebar-border/70',
|
|
109
148
|
);
|
|
110
149
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
150
|
+
const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
|
|
151
|
+
const headerLabel = headerLabelText ? (
|
|
152
|
+
<DropdownMenuLabel className="truncate px-2 py-1.5 text-xs font-normal text-muted-foreground">
|
|
153
|
+
{headerLabelText}
|
|
154
|
+
</DropdownMenuLabel>
|
|
155
|
+
) : null;
|
|
114
156
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const accountLinksItems = accountLinks.map((item) => {
|
|
158
|
+
const Icon = item.icon;
|
|
159
|
+
return (
|
|
160
|
+
<DropdownMenuItem key={item.href} asChild>
|
|
161
|
+
<Link
|
|
162
|
+
href={item.href!}
|
|
163
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
164
|
+
>
|
|
165
|
+
{Icon ? <Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> : null}
|
|
166
|
+
<span className="truncate">{item.label}</span>
|
|
167
|
+
</Link>
|
|
168
|
+
</DropdownMenuItem>
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
const accountLinksBlock = accountLinks.length > 0 ? (
|
|
172
|
+
<>
|
|
173
|
+
{headerLabel ? <DropdownMenuSeparator /> : null}
|
|
174
|
+
{accountLinksItems}
|
|
175
|
+
</>
|
|
176
|
+
) : null;
|
|
132
177
|
|
|
133
|
-
const
|
|
134
|
-
|
|
178
|
+
const currentLocaleLabel = layoutI18n
|
|
179
|
+
? getLocaleMeta(layoutI18n.locale).native
|
|
180
|
+
: null;
|
|
181
|
+
const languageItem = layoutI18n ? (
|
|
182
|
+
<DropdownMenuItem
|
|
183
|
+
onSelect={onLanguageSelect}
|
|
184
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
185
|
+
>
|
|
186
|
+
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
187
|
+
<span className="flex-1 truncate">Language</span>
|
|
188
|
+
<span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
|
189
|
+
{currentLocaleLabel}
|
|
190
|
+
<ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
|
191
|
+
</span>
|
|
192
|
+
</DropdownMenuItem>
|
|
135
193
|
) : null;
|
|
136
194
|
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
195
|
+
const themeIcon = theme === 'dark'
|
|
196
|
+
? <Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
197
|
+
: theme === 'light'
|
|
198
|
+
? <Sun className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
199
|
+
: <Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
|
200
|
+
const themeValueLabel = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';
|
|
201
|
+
const themeItem = (
|
|
202
|
+
<DropdownMenuItem
|
|
203
|
+
onSelect={onThemeSelect}
|
|
204
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
205
|
+
>
|
|
206
|
+
{themeIcon}
|
|
207
|
+
<span className="flex-1 truncate">Theme</span>
|
|
208
|
+
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{themeValueLabel}</span>
|
|
209
|
+
</DropdownMenuItem>
|
|
210
|
+
);
|
|
143
211
|
|
|
144
|
-
const
|
|
212
|
+
const expandedMeta = narrow ? null : (
|
|
145
213
|
<>
|
|
146
|
-
<span className="min-w-0 flex-1
|
|
147
|
-
|
|
214
|
+
<span className="flex min-w-0 flex-1 flex-col text-left">
|
|
215
|
+
<span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
|
|
216
|
+
{account.displayName}
|
|
217
|
+
</span>
|
|
218
|
+
{account.plan ? (
|
|
219
|
+
<span className="truncate text-xs leading-snug text-sidebar-foreground/60">
|
|
220
|
+
{account.plan}
|
|
221
|
+
</span>
|
|
222
|
+
) : null}
|
|
223
|
+
</span>
|
|
224
|
+
<span className="flex shrink-0 items-center gap-1.5">
|
|
225
|
+
{secondaryButton}
|
|
226
|
+
<ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
|
|
148
227
|
</span>
|
|
149
|
-
<ChevronDown className={chevronClassName} aria-hidden />
|
|
150
228
|
</>
|
|
151
229
|
);
|
|
152
230
|
|
|
153
|
-
const localeThemeGroup = layoutI18n ? (
|
|
154
|
-
<LocaleSwitcher
|
|
155
|
-
variant="dropdown"
|
|
156
|
-
buttonVariant="ghost"
|
|
157
|
-
size="icon"
|
|
158
|
-
showTriggerLabel={false}
|
|
159
|
-
showIcon={false}
|
|
160
|
-
className="h-8 w-8 shrink-0 text-base leading-none"
|
|
161
|
-
/>
|
|
162
|
-
) : null;
|
|
163
|
-
|
|
164
231
|
return (
|
|
165
|
-
<div
|
|
166
|
-
<
|
|
167
|
-
<
|
|
232
|
+
<div className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
|
|
233
|
+
<DropdownMenu>
|
|
234
|
+
<DropdownMenuTrigger asChild>
|
|
168
235
|
<Button
|
|
169
236
|
type="button"
|
|
170
237
|
variant="ghost"
|
|
171
|
-
aria-
|
|
172
|
-
aria-label={narrow ? 'Account' : undefined}
|
|
238
|
+
aria-label={narrow ? account.displayName : undefined}
|
|
173
239
|
className={triggerClassName}
|
|
174
|
-
onClick={
|
|
240
|
+
onClick={onTriggerInteract}
|
|
175
241
|
>
|
|
176
|
-
<Avatar className=
|
|
177
|
-
<AvatarImage src={
|
|
178
|
-
<AvatarFallback className="text-
|
|
242
|
+
<Avatar className={avatarClass}>
|
|
243
|
+
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
244
|
+
<AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
|
|
179
245
|
</Avatar>
|
|
180
|
-
{
|
|
246
|
+
{expandedMeta}
|
|
181
247
|
</Button>
|
|
182
|
-
</
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
</DropdownMenuTrigger>
|
|
249
|
+
|
|
250
|
+
<DropdownMenuContent
|
|
251
|
+
side={dropdownSide}
|
|
252
|
+
align="start"
|
|
253
|
+
sideOffset={8}
|
|
254
|
+
className={dropdownContentClass}
|
|
255
|
+
>
|
|
256
|
+
{headerLabel}
|
|
257
|
+
{accountLinksBlock}
|
|
258
|
+
|
|
259
|
+
<DropdownMenuSeparator />
|
|
260
|
+
{languageItem}
|
|
261
|
+
{themeItem}
|
|
262
|
+
|
|
263
|
+
<DropdownMenuSeparator />
|
|
264
|
+
<DropdownMenuItem
|
|
265
|
+
onSelect={onLogoutSelect}
|
|
266
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive focus:bg-destructive/10 focus:text-destructive"
|
|
267
|
+
>
|
|
268
|
+
<LogOut className="h-4 w-4 shrink-0" />
|
|
269
|
+
<span className="truncate">{signOutLabel}</span>
|
|
270
|
+
</DropdownMenuItem>
|
|
271
|
+
</DropdownMenuContent>
|
|
272
|
+
</DropdownMenu>
|
|
273
|
+
|
|
274
|
+
{layoutI18n ? (
|
|
275
|
+
<LocaleSwitcherDialog
|
|
276
|
+
open={langDialogOpen}
|
|
277
|
+
onOpenChange={setLangDialogOpen}
|
|
278
|
+
locale={layoutI18n.locale}
|
|
279
|
+
locales={layoutI18n.locales}
|
|
280
|
+
onChange={layoutI18n.onLocaleChange}
|
|
281
|
+
brand={layoutI18n.brand}
|
|
282
|
+
i18nLabels={layoutI18n.dialogLabels}
|
|
283
|
+
/>
|
|
284
|
+
) : null}
|
|
213
285
|
</div>
|
|
214
286
|
);
|
|
215
287
|
}
|
|
288
|
+
|
|
289
|
+
interface SecondaryActionProps {
|
|
290
|
+
action: NonNullable<HeaderConfig['footerSecondaryAction']>;
|
|
291
|
+
onParentExpand: () => void;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
295
|
+
// Radix's DropdownMenuTrigger opens on pointerdown, before React onClick fires.
|
|
296
|
+
// Stop the event there too — onClick alone is too late.
|
|
297
|
+
const stop = (e: React.SyntheticEvent) => {
|
|
298
|
+
e.stopPropagation();
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
303
|
+
stop(e);
|
|
304
|
+
onParentExpand();
|
|
305
|
+
action.onClick?.();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const inner = (
|
|
309
|
+
<span
|
|
310
|
+
className={cn(
|
|
311
|
+
'relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
|
312
|
+
'border border-sidebar-border/40 bg-transparent text-sidebar-foreground/70',
|
|
313
|
+
'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
|
314
|
+
)}
|
|
315
|
+
>
|
|
316
|
+
<LucideIconRender icon={action.icon} className="h-4 w-4" />
|
|
317
|
+
{action.pulse ? (
|
|
318
|
+
<span className="pointer-events-none absolute -right-0.5 -top-0.5 flex h-2 w-2">
|
|
319
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
|
320
|
+
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
|
321
|
+
</span>
|
|
322
|
+
) : null}
|
|
323
|
+
</span>
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (action.href) {
|
|
327
|
+
return (
|
|
328
|
+
<Link
|
|
329
|
+
href={action.href}
|
|
330
|
+
aria-label={action.ariaLabel}
|
|
331
|
+
onClick={handleClick}
|
|
332
|
+
onPointerDown={stop}
|
|
333
|
+
onPointerUp={stop}
|
|
334
|
+
data-no-expand
|
|
335
|
+
>
|
|
336
|
+
{inner}
|
|
337
|
+
</Link>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
aria-label={action.ariaLabel}
|
|
345
|
+
onClick={handleClick}
|
|
346
|
+
onPointerDown={stop}
|
|
347
|
+
onPointerUp={stop}
|
|
348
|
+
data-no-expand
|
|
349
|
+
>
|
|
350
|
+
{inner}
|
|
351
|
+
</button>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured CTA tile for the sidebar — accent-tinted plate with icon, label,
|
|
3
|
+
* optional inline badge, and a trailing arrow. Mailersend-style.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { ArrowRight } from 'lucide-react';
|
|
9
|
+
import { Link } from '@djangocfg/ui-core/components';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
import { LucideIcon } from '../../components';
|
|
15
|
+
|
|
16
|
+
import type { SidebarFeaturedConfig } from '../PrivateLayout/PrivateLayout';
|
|
17
|
+
|
|
18
|
+
const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
|
|
19
|
+
green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
|
|
20
|
+
blue: 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 dark:text-sky-300 dark:bg-sky-400/10 dark:hover:bg-sky-400/15',
|
|
21
|
+
amber: 'bg-amber-500/10 text-amber-800 hover:bg-amber-500/15 dark:text-amber-300 dark:bg-amber-400/10 dark:hover:bg-amber-400/15',
|
|
22
|
+
primary: 'bg-primary/10 text-primary hover:bg-primary/15',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BADGE_ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
|
|
26
|
+
green: 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950',
|
|
27
|
+
blue: 'bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950',
|
|
28
|
+
amber: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950',
|
|
29
|
+
primary: 'bg-primary text-primary-foreground',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface SidebarFeaturedProps {
|
|
33
|
+
config: SidebarFeaturedConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SidebarFeatured({ config }: SidebarFeaturedProps) {
|
|
37
|
+
const accent = config.accent ?? 'green';
|
|
38
|
+
const tileClass = cn(
|
|
39
|
+
'group/featured flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2',
|
|
40
|
+
'transition-colors',
|
|
41
|
+
ACCENT_CLASS[accent],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const badgeNode = config.badge ? (
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
'inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
|
48
|
+
BADGE_ACCENT_CLASS[accent],
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{config.badge}
|
|
52
|
+
</span>
|
|
53
|
+
) : null;
|
|
54
|
+
|
|
55
|
+
const iconNode = config.icon ? (
|
|
56
|
+
<LucideIcon icon={config.icon} className="h-4 w-4 shrink-0" />
|
|
57
|
+
) : null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Link href={config.href} className={tileClass}>
|
|
61
|
+
{iconNode}
|
|
62
|
+
{badgeNode}
|
|
63
|
+
<span className="flex-1 truncate text-sm font-medium">{config.label}</span>
|
|
64
|
+
<ArrowRight
|
|
65
|
+
className="h-4 w-4 shrink-0 transition-transform duration-200 group-hover/featured:translate-x-0.5"
|
|
66
|
+
aria-hidden
|
|
67
|
+
/>
|
|
68
|
+
</Link>
|
|
69
|
+
);
|
|
70
|
+
}
|