@djangocfg/layouts 2.1.257 → 2.1.260
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 +101 -203
- package/package.json +18 -18
- package/src/index.ts +4 -1
- package/src/layouts/AppLayout/AppLayout.tsx +97 -8
- package/src/layouts/AppLayout/BaseApp.tsx +2 -0
- package/src/layouts/AppLayout/index.ts +6 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +5 -1
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +12 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +45 -14
- package/src/layouts/PublicLayout/PublicLayout.tsx +31 -8
- package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +17 -24
- package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +79 -95
- package/src/layouts/PublicLayout/components/PublicFooter/index.ts +2 -0
- package/src/layouts/PublicLayout/components/PublicFooter/types.ts +41 -31
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +69 -30
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +24 -34
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +162 -94
- package/src/layouts/PublicLayout/components/ThemeBrandMark.tsx +83 -0
- package/src/layouts/PublicLayout/components/index.ts +2 -0
- package/src/layouts/PublicLayout/index.ts +5 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +8 -0
- package/src/layouts/PublicLayout/publicShellShadow.ts +12 -0
- package/src/layouts/_components/UserMenu.tsx +2 -2
- package/src/layouts/types/index.ts +9 -1
- package/src/layouts/types/providers.types.ts +10 -0
- package/src/theme/ThemeStyleBridge.tsx +41 -0
- package/src/theme/buildThemeStyleSheet.ts +71 -0
- package/src/theme/index.ts +16 -0
- package/src/theme/themeStyle.types.ts +89 -0
- package/src/theme/themeStylePresets.ts +202 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public Layout Navigation
|
|
3
3
|
*
|
|
4
|
-
* Navigation component for PublicLayout with mobile drawer support
|
|
4
|
+
* Navigation component for PublicLayout with mobile drawer support.
|
|
5
|
+
*
|
|
6
|
+
* Colors: use Tailwind semantic utilities backed by `@djangocfg/ui-core` theme
|
|
7
|
+
* (`packages/ui-core/src/styles/theme/light.css`, `dark.css`, `tokens.css`).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
'use client';
|
|
@@ -23,12 +26,12 @@ import {
|
|
|
23
26
|
Button,
|
|
24
27
|
} from '@djangocfg/ui-core/components';
|
|
25
28
|
import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
|
|
26
|
-
|
|
27
|
-
import { cn as _cn } from '@djangocfg/ui-core/lib';
|
|
29
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
28
30
|
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
29
31
|
|
|
30
32
|
import { UserMenu } from '../../_components/UserMenu';
|
|
31
33
|
import { usePublicLayoutOptional } from '../context';
|
|
34
|
+
import { publicFloatingChromeClassName } from '../publicShellShadow';
|
|
32
35
|
import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
|
|
33
36
|
|
|
34
37
|
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
@@ -47,12 +50,12 @@ export interface PublicDesktopDropdownRenderProps {
|
|
|
47
50
|
export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
|
|
48
51
|
|
|
49
52
|
interface PublicNavigationProps {
|
|
50
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Brand area: any React node, or a plain string (wrapped in `<Link href={brandHref}>` as the title).
|
|
55
|
+
*/
|
|
51
56
|
brand?: ReactNode;
|
|
52
|
-
/**
|
|
57
|
+
/** Used when `brand` is a string; also for deduping a lone nav link that matches this href (desktop). @default '/' */
|
|
53
58
|
brandHref?: string;
|
|
54
|
-
logo?: string;
|
|
55
|
-
siteName?: string;
|
|
56
59
|
navigation?: NavigationItem[];
|
|
57
60
|
userMenu?: UserMenuConfig;
|
|
58
61
|
containerClassName?: string;
|
|
@@ -61,6 +64,11 @@ interface PublicNavigationProps {
|
|
|
61
64
|
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
62
65
|
/** Max visible top-level desktop items before collapsing into "More" */
|
|
63
66
|
desktopMaxPrimaryItems?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Tailwind rounding for the floating navbar shell (e.g. `rounded-3xl`). Defaults to `rounded-2xl`.
|
|
69
|
+
* Prefer `PublicNavbar` `config.shell.rounding` (or `AppLayout` `publicChrome.navbar.shell.rounding`).
|
|
70
|
+
*/
|
|
71
|
+
rounding?: string;
|
|
64
72
|
mobileMenuOpen?: boolean;
|
|
65
73
|
onMobileMenuToggle?: () => void;
|
|
66
74
|
}
|
|
@@ -69,11 +77,10 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
69
77
|
const context = usePublicLayoutOptional();
|
|
70
78
|
const brand = props.brand;
|
|
71
79
|
const brandHref = props.brandHref ?? '/';
|
|
72
|
-
const logo = props.logo;
|
|
73
|
-
const siteName = props.siteName ?? 'App';
|
|
74
80
|
const navigation = props.navigation ?? [];
|
|
75
81
|
const userMenu = props.userMenu;
|
|
76
82
|
const containerClassName = props.containerClassName;
|
|
83
|
+
const rounding = props.rounding;
|
|
77
84
|
const navbarVariant = props.navbarVariant ?? 'floating';
|
|
78
85
|
const navbarPosition = props.navbarPosition ?? 'sticky';
|
|
79
86
|
const renderDesktopDropdown = props.renderDesktopDropdown;
|
|
@@ -90,8 +97,31 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
90
97
|
const navOuterRef = useRef<HTMLDivElement | null>(null);
|
|
91
98
|
|
|
92
99
|
const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
|
|
101
|
+
/** Long i18n labels: ellipsis instead of stretching the bar (desktop top-level + “More”). */
|
|
102
|
+
const desktopNavLabelClass = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
|
|
103
|
+
/** Dropdown / overflow menu rows — a bit wider than top-level pills. */
|
|
104
|
+
/** Top-level desktop nav control — larger hit target, works in light/dark. */
|
|
105
|
+
const desktopNavItemClass = cn(
|
|
106
|
+
// No `border-transparent` (1px “ghost” stroke still paints); use `border-0` and only add stroke in dark when needed.
|
|
107
|
+
'inline-flex min-h-9 items-center justify-center gap-1 rounded-full border-0 px-4 py-1.5 text-sm font-medium',
|
|
108
|
+
'ring-0 focus-visible:ring-0',
|
|
109
|
+
'text-foreground/90 transition-colors',
|
|
110
|
+
'hover:bg-accent/55 hover:text-foreground',
|
|
111
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
112
|
+
);
|
|
113
|
+
/** Light: fill only. Dark: optional hairline + muted fill. */
|
|
114
|
+
const desktopNavItemActiveClass =
|
|
115
|
+
'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none';
|
|
116
|
+
|
|
117
|
+
const subMenuLinkClass = (active: boolean) =>
|
|
118
|
+
cn(
|
|
119
|
+
'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-full border-0 px-4 py-2 text-sm font-medium transition-colors',
|
|
120
|
+
'hover:bg-accent/55',
|
|
121
|
+
active
|
|
122
|
+
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
|
|
123
|
+
: 'border-0 text-foreground/90',
|
|
124
|
+
);
|
|
95
125
|
|
|
96
126
|
const clearOpenTimer = () => {
|
|
97
127
|
if (openTimerRef.current) {
|
|
@@ -192,17 +222,15 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
192
222
|
return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
|
|
193
223
|
})();
|
|
194
224
|
|
|
195
|
-
const navInnerClassName = (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return `${base} ${visual} ${containerClassName || ''}`.trim().replace(/\s+/g, ' ');
|
|
205
|
-
})();
|
|
225
|
+
const navInnerClassName = cn(
|
|
226
|
+
'mx-auto w-full',
|
|
227
|
+
navbarVariant === 'floating'
|
|
228
|
+
? cn(rounding ?? 'rounded-2xl', publicFloatingChromeClassName)
|
|
229
|
+
: 'rounded-none border-x-0 border-t-0 border-b border-border/40 dark:border-border/70 shadow-none',
|
|
230
|
+
containerClassName,
|
|
231
|
+
// Last: guarantee shell has no stroke on light; `containerClassName` cannot reintroduce a border.
|
|
232
|
+
navbarVariant === 'floating' && '!border-0 dark:!border dark:!border-border/75',
|
|
233
|
+
);
|
|
206
234
|
|
|
207
235
|
const isActivePath = (href: string) => {
|
|
208
236
|
if (href === '/') return pathname === '/';
|
|
@@ -216,41 +244,50 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
216
244
|
};
|
|
217
245
|
|
|
218
246
|
const closeDropdown = () => setOpenDropdownKey(null);
|
|
219
|
-
|
|
220
|
-
|
|
247
|
+
|
|
248
|
+
/** Desktop: omit a lone top-level link that duplicates the brand link — saves space; drawer keeps full list. */
|
|
249
|
+
const desktopNavItems = useMemo(() => {
|
|
250
|
+
return navigation.filter((item) => {
|
|
251
|
+
if (item.items && item.items.length > 0) return true;
|
|
252
|
+
return item.href !== brandHref;
|
|
253
|
+
});
|
|
254
|
+
}, [navigation, brandHref]);
|
|
255
|
+
|
|
256
|
+
const desktopPrimaryNavigation = desktopNavItems.slice(0, desktopMaxPrimaryItems);
|
|
257
|
+
const desktopOverflowNavigation = desktopNavItems.slice(desktopMaxPrimaryItems);
|
|
221
258
|
|
|
222
259
|
const renderDesktopNavItem = (item: NavigationItem) => {
|
|
223
260
|
if (item.items && item.items.length > 0) {
|
|
224
261
|
const dropdownKey = `${item.label}-${item.href}`;
|
|
225
262
|
const defaultItems = (
|
|
226
263
|
<>
|
|
227
|
-
{item.items.map((subItem) =>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
264
|
+
{item.items.map((subItem) => {
|
|
265
|
+
const subActive = isActivePath(subItem.href);
|
|
266
|
+
return (
|
|
267
|
+
<div key={`${item.label}-${subItem.href}`} className="rounded-full">
|
|
268
|
+
{subItem.external ? (
|
|
269
|
+
<a
|
|
270
|
+
href={subItem.href}
|
|
271
|
+
target="_blank"
|
|
272
|
+
rel="noopener noreferrer"
|
|
273
|
+
className={subMenuLinkClass(subActive)}
|
|
274
|
+
>
|
|
275
|
+
<span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
|
|
276
|
+
</a>
|
|
277
|
+
) : (
|
|
278
|
+
<Link href={subItem.href} className={subMenuLinkClass(subActive)}>
|
|
279
|
+
<span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
|
|
280
|
+
</Link>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
248
285
|
</>
|
|
249
286
|
);
|
|
250
287
|
|
|
251
288
|
const defaultPopover = (
|
|
252
289
|
<div
|
|
253
|
-
className="absolute left-0 top-full mt-1 z-[1200] min-w-
|
|
290
|
+
className="absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
|
|
254
291
|
onMouseEnter={() => {
|
|
255
292
|
clearOpenTimer();
|
|
256
293
|
clearCloseTimer();
|
|
@@ -273,12 +310,23 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
273
310
|
<Button
|
|
274
311
|
variant="ghost"
|
|
275
312
|
size="sm"
|
|
276
|
-
className={
|
|
313
|
+
className={cn(
|
|
314
|
+
// Override Button base [&_svg]:size-16px so hover/layout doesn’t fight icon size; fixed chevron box avoids neighbor jitter when rotating.
|
|
315
|
+
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
316
|
+
desktopNavItemClass,
|
|
317
|
+
(isOpen || isActive) && desktopNavItemActiveClass,
|
|
318
|
+
isOpen && 'border-0 dark:border dark:border-border',
|
|
319
|
+
)}
|
|
277
320
|
>
|
|
278
|
-
<span>{item.label}</span>
|
|
279
|
-
<
|
|
280
|
-
className=
|
|
281
|
-
|
|
321
|
+
<span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
|
|
322
|
+
<span
|
|
323
|
+
className="inline-flex size-3.5 shrink-0 items-center justify-center"
|
|
324
|
+
aria-hidden
|
|
325
|
+
>
|
|
326
|
+
<ChevronDown
|
|
327
|
+
className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
328
|
+
/>
|
|
329
|
+
</span>
|
|
282
330
|
</Button>
|
|
283
331
|
|
|
284
332
|
{isOpen && (
|
|
@@ -297,41 +345,54 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
297
345
|
);
|
|
298
346
|
}
|
|
299
347
|
|
|
348
|
+
const linkActive = isActivePath(item.href);
|
|
300
349
|
return (
|
|
301
350
|
<Link
|
|
302
351
|
key={item.href}
|
|
303
352
|
href={item.href}
|
|
304
|
-
className={
|
|
353
|
+
className={cn(desktopNavItemClass, linkActive && desktopNavItemActiveClass)}
|
|
305
354
|
>
|
|
306
|
-
{item.label}
|
|
355
|
+
<span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
|
|
307
356
|
</Link>
|
|
308
357
|
);
|
|
309
358
|
};
|
|
310
359
|
|
|
360
|
+
const hasDesktopOverflowNav = desktopOverflowNavigation.length > 0;
|
|
361
|
+
const isMoreMenuOpen = openDropdownKey === '__overflow-more';
|
|
362
|
+
const moreMenuButtonActive =
|
|
363
|
+
isMoreMenuOpen || desktopOverflowNavigation.some((navItem) => isGroupActive(navItem));
|
|
364
|
+
|
|
311
365
|
return (
|
|
312
366
|
<div ref={navOuterRef} className={navOuterClassName}>
|
|
313
367
|
<nav
|
|
314
|
-
className={
|
|
315
|
-
|
|
368
|
+
className={cn(
|
|
369
|
+
navInnerClassName,
|
|
370
|
+
// Light: glass over page bg. Dark: slightly lifted vs --background (see ui-core theme/dark.css --card).
|
|
371
|
+
'bg-background/72 backdrop-blur-[10px] dark:bg-card/80',
|
|
372
|
+
)}
|
|
316
373
|
>
|
|
317
374
|
<div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
|
|
318
375
|
<div className="flex items-center justify-between py-3.5">
|
|
319
|
-
{/*
|
|
320
|
-
|
|
321
|
-
brand
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
376
|
+
{/* Brand */}
|
|
377
|
+
<div className="min-w-0 shrink-0 flex items-center">
|
|
378
|
+
{brand != null && brand !== '' && brand !== false
|
|
379
|
+
? typeof brand === 'string'
|
|
380
|
+
? (
|
|
381
|
+
<Link
|
|
382
|
+
href={brandHref}
|
|
383
|
+
className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
|
|
384
|
+
>
|
|
385
|
+
{brand}
|
|
386
|
+
</Link>
|
|
387
|
+
)
|
|
388
|
+
: brand
|
|
389
|
+
: null}
|
|
390
|
+
</div>
|
|
330
391
|
|
|
331
392
|
{/* Desktop Navigation */}
|
|
332
|
-
<div className="hidden lg:flex items-center gap-
|
|
393
|
+
<div className="hidden isolate lg:flex items-center gap-1">
|
|
333
394
|
{desktopPrimaryNavigation.map(renderDesktopNavItem)}
|
|
334
|
-
{
|
|
395
|
+
{hasDesktopOverflowNav && (
|
|
335
396
|
<div
|
|
336
397
|
className="relative"
|
|
337
398
|
onMouseEnter={() => scheduleOpen('__overflow-more')}
|
|
@@ -340,41 +401,48 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
340
401
|
<Button
|
|
341
402
|
variant="ghost"
|
|
342
403
|
size="sm"
|
|
343
|
-
className={
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
404
|
+
className={cn(
|
|
405
|
+
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
406
|
+
desktopNavItemClass,
|
|
407
|
+
moreMenuButtonActive && desktopNavItemActiveClass,
|
|
408
|
+
isMoreMenuOpen && 'border-0 dark:border dark:border-border',
|
|
409
|
+
)}
|
|
348
410
|
>
|
|
349
|
-
<span>More</span>
|
|
350
|
-
<
|
|
351
|
-
className=
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
411
|
+
<span className={desktopNavLabelClass}>More</span>
|
|
412
|
+
<span
|
|
413
|
+
className="inline-flex size-3.5 shrink-0 items-center justify-center"
|
|
414
|
+
aria-hidden
|
|
415
|
+
>
|
|
416
|
+
<ChevronDown
|
|
417
|
+
className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${
|
|
418
|
+
isMoreMenuOpen ? 'rotate-180' : ''
|
|
419
|
+
}`}
|
|
420
|
+
/>
|
|
421
|
+
</span>
|
|
355
422
|
</Button>
|
|
356
423
|
|
|
357
|
-
{
|
|
424
|
+
{isMoreMenuOpen && (
|
|
358
425
|
<div
|
|
359
|
-
className="absolute right-0 top-full mt-1 z-[1200] min-w-
|
|
426
|
+
className="absolute right-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
|
|
360
427
|
onMouseEnter={() => {
|
|
361
428
|
clearOpenTimer();
|
|
362
429
|
clearCloseTimer();
|
|
363
430
|
}}
|
|
364
431
|
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
365
432
|
>
|
|
366
|
-
{desktopOverflowNavigation.map((
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
433
|
+
{desktopOverflowNavigation.map((navItem) => {
|
|
434
|
+
const overflowActive = isGroupActive(navItem);
|
|
435
|
+
return (
|
|
436
|
+
<div key={`overflow-${navItem.href}`} className="rounded-full">
|
|
437
|
+
<Link
|
|
438
|
+
href={navItem.href}
|
|
439
|
+
className={subMenuLinkClass(overflowActive)}
|
|
440
|
+
>
|
|
441
|
+
<span className="min-w-0 truncate" title={navItem.label}>{navItem.label}</span>
|
|
442
|
+
</Link>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
378
446
|
</div>
|
|
379
447
|
)}
|
|
380
448
|
</div>
|
|
@@ -401,7 +469,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
|
|
|
401
469
|
size="icon"
|
|
402
470
|
aria-label={toggleMobileLabel}
|
|
403
471
|
data-mobile-menu-trigger="true"
|
|
404
|
-
className="lg:hidden"
|
|
472
|
+
className="lg:hidden rounded-full"
|
|
405
473
|
onClick={toggleMobileMenu}
|
|
406
474
|
>
|
|
407
475
|
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-aware brand mark for public chrome (navbar, footer).
|
|
3
|
+
*
|
|
4
|
+
* Uses two layers and Tailwind `dark:` so the correct asset is chosen with **no JS theme hook**
|
|
5
|
+
* and **no hydration mismatch** — the same thing `next-themes` does for `html.dark` / class strategy.
|
|
6
|
+
*
|
|
7
|
+
* Prefer this over `useTheme().resolvedTheme` + one `<img>` unless you have a strong reason
|
|
8
|
+
* (single-DOM node only).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use client';
|
|
12
|
+
|
|
13
|
+
import React, { type ReactNode } from 'react';
|
|
14
|
+
|
|
15
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
16
|
+
|
|
17
|
+
export interface ThemeBrandMarkProps {
|
|
18
|
+
/**
|
|
19
|
+
* Mark when the app is in **light** appearance (`html` without `.dark`).
|
|
20
|
+
* Usually a dark-colored logo on a light bar.
|
|
21
|
+
*/
|
|
22
|
+
light: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Mark when the app is in **dark** appearance (`html.dark`).
|
|
25
|
+
* Usually a light-colored logo on a dark bar.
|
|
26
|
+
*/
|
|
27
|
+
dark: ReactNode;
|
|
28
|
+
/** Outer wrapper — keep sizing here (e.g. `h-5.5 w-auto`). */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** `role="img"` label when children are decorative. */
|
|
31
|
+
'aria-label'?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Renders two branches; only one is visible. Same idea as pairing
|
|
36
|
+
* `className="dark:hidden"` / `className="hidden dark:block"` on `<img>`, but works with
|
|
37
|
+
* any node (SVG component, picture, etc.).
|
|
38
|
+
*/
|
|
39
|
+
export function ThemeBrandMark({ light, dark, className, 'aria-label': ariaLabel }: ThemeBrandMarkProps) {
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
className={cn('inline-flex shrink-0 items-center justify-center', className)}
|
|
43
|
+
role={ariaLabel ? 'img' : undefined}
|
|
44
|
+
aria-label={ariaLabel}
|
|
45
|
+
>
|
|
46
|
+
<span className="flex items-center justify-center dark:hidden">{light}</span>
|
|
47
|
+
<span className="hidden items-center justify-center dark:flex">{dark}</span>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ThemeBrandMarkImgProps {
|
|
53
|
+
/** Logo URL for light UI (no `html.dark`). */
|
|
54
|
+
srcLight: string;
|
|
55
|
+
/** Logo URL for dark UI (`html.dark`). */
|
|
56
|
+
srcDark: string;
|
|
57
|
+
alt?: string;
|
|
58
|
+
/** Applied to both images (e.g. `h-5.5 w-auto object-contain`). */
|
|
59
|
+
className?: string;
|
|
60
|
+
wrapperClassName?: string;
|
|
61
|
+
'aria-label'?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convenience wrapper around {@link ThemeBrandMark} for the common “two raster/SVG URLs” case.
|
|
66
|
+
*/
|
|
67
|
+
export function ThemeBrandMarkImg({
|
|
68
|
+
srcLight,
|
|
69
|
+
srcDark,
|
|
70
|
+
alt = '',
|
|
71
|
+
className,
|
|
72
|
+
wrapperClassName,
|
|
73
|
+
'aria-label': ariaLabel,
|
|
74
|
+
}: ThemeBrandMarkImgProps) {
|
|
75
|
+
return (
|
|
76
|
+
<ThemeBrandMark
|
|
77
|
+
className={wrapperClassName}
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
light={<img src={srcLight} alt={alt} className={className} />}
|
|
80
|
+
dark={<img src={srcDark} alt={alt} className={className} />}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -6,4 +6,6 @@ export { PublicNavigation } from './PublicNavigation';
|
|
|
6
6
|
export { PublicMobileDrawer } from './PublicMobileDrawer';
|
|
7
7
|
export { PublicNavbar } from './PublicNavbar';
|
|
8
8
|
export { PublicFooter } from './PublicFooter';
|
|
9
|
+
export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
|
|
10
|
+
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
|
|
9
11
|
|
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
PublicNavbarSurface,
|
|
10
10
|
PublicNavbarVariant,
|
|
11
11
|
PublicNavbarPosition,
|
|
12
|
+
PublicNavbarShellConfig,
|
|
12
13
|
} from './navbarTypes';
|
|
13
14
|
export type {
|
|
14
15
|
PublicDesktopDropdownRenderer,
|
|
@@ -23,8 +24,12 @@ export {
|
|
|
23
24
|
FooterSocialLinksComponent,
|
|
24
25
|
DjangoCFGLogo,
|
|
25
26
|
} from './components/PublicFooter';
|
|
27
|
+
export { ThemeBrandMark, ThemeBrandMarkImg } from './components/ThemeBrandMark';
|
|
28
|
+
export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './components/ThemeBrandMark';
|
|
26
29
|
export type {
|
|
27
30
|
PublicFooterProps,
|
|
31
|
+
PublicFooterConfig,
|
|
32
|
+
FooterProjectInfoProps,
|
|
28
33
|
} from './components/PublicFooter';
|
|
29
34
|
export { PublicLayoutProvider, usePublicLayout } from './context';
|
|
30
35
|
|
|
@@ -10,3 +10,11 @@ export interface PublicNavbarSurface {
|
|
|
10
10
|
variant: PublicNavbarVariant;
|
|
11
11
|
position: PublicNavbarPosition;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/** Floating bar + mobile drawer shell (rounding, width / centering). */
|
|
15
|
+
export interface PublicNavbarShellConfig {
|
|
16
|
+
/** Tailwind rounding class (e.g. `rounded-3xl`). */
|
|
17
|
+
rounding?: string;
|
|
18
|
+
/** Strip + drawer wrapper (e.g. `mx-auto max-w-7xl`). */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared “floating glass” chrome for navbar + mobile drawer.
|
|
3
|
+
*
|
|
4
|
+
* Light: barely-there lift. Dark: one soft outer shadow (border already defines the edge).
|
|
5
|
+
*/
|
|
6
|
+
export const publicFloatingChromeClassName =
|
|
7
|
+
[
|
|
8
|
+
'!border-0 ring-0 outline-none',
|
|
9
|
+
'shadow-[0_1px_6px_rgba(0,0,0,0.028)]',
|
|
10
|
+
'dark:!border dark:!border-border/75',
|
|
11
|
+
'dark:shadow-[0_3px_14px_rgba(0,0,0,0.07)]',
|
|
12
|
+
].join(' ');
|
|
@@ -138,7 +138,7 @@ export function UserMenu({
|
|
|
138
138
|
<div className="pt-4 border-t border-border/50">
|
|
139
139
|
<Link
|
|
140
140
|
href={authPath}
|
|
141
|
-
className="group flex items-center justify-between rounded-
|
|
141
|
+
className="group flex items-center justify-between rounded-full px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
|
|
142
142
|
>
|
|
143
143
|
<span>{labels.signIn}</span>
|
|
144
144
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
@@ -148,7 +148,7 @@ export function UserMenu({
|
|
|
148
148
|
}
|
|
149
149
|
return (
|
|
150
150
|
<Link href={authPath}>
|
|
151
|
-
<Button variant="default" size="sm">
|
|
151
|
+
<Button variant="default" size="sm" className="rounded-full px-5">
|
|
152
152
|
{labels.signIn}
|
|
153
153
|
</Button>
|
|
154
154
|
</Link>
|
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
// ============================================================================
|
|
11
11
|
|
|
12
12
|
// Local provider configs
|
|
13
|
-
export type {
|
|
13
|
+
export type {
|
|
14
|
+
ThemeConfig,
|
|
15
|
+
ThemeStyleConfig,
|
|
16
|
+
ThemeCssVarKey,
|
|
17
|
+
ThemeCssVarMap,
|
|
18
|
+
ThemeStylePresetId,
|
|
19
|
+
SWRConfigOptions,
|
|
20
|
+
CentrifugoConfig,
|
|
21
|
+
} from './providers.types';
|
|
14
22
|
|
|
15
23
|
// External provider configs (re-export for convenience)
|
|
16
24
|
export type { AnalyticsConfig } from '../../snippets/Analytics/types';
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Note: Analytics, PWA, Push, and Error types are defined in their respective modules
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { ThemeStyleConfig } from '../../theme/themeStyle.types';
|
|
9
|
+
|
|
10
|
+
// Re-export for consumers that only import from `layouts/types`
|
|
11
|
+
export type { ThemeStyleConfig, ThemeCssVarKey, ThemeCssVarMap, ThemeStylePresetId } from '../../theme/themeStyle.types';
|
|
12
|
+
|
|
8
13
|
// ============================================================================
|
|
9
14
|
// Theme Configuration
|
|
10
15
|
// ============================================================================
|
|
@@ -12,6 +17,11 @@
|
|
|
12
17
|
export interface ThemeConfig {
|
|
13
18
|
defaultTheme?: 'light' | 'dark' | 'system';
|
|
14
19
|
storageKey?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Typed CSS variable presets and per-mode overrides (see `ThemeStyleBridge` in BaseApp).
|
|
22
|
+
* For heavy visual editing, use the playground Theme Configurator and export CSS instead.
|
|
23
|
+
*/
|
|
24
|
+
style?: ThemeStyleConfig;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
// ============================================================================
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import { buildThemeStyleSheet } from './buildThemeStyleSheet';
|
|
6
|
+
|
|
7
|
+
import type { ThemeStyleConfig } from './themeStyle.types';
|
|
8
|
+
|
|
9
|
+
const STYLE_ELEMENT_ID = 'djangocfg-baseapp-theme-style';
|
|
10
|
+
|
|
11
|
+
export interface ThemeStyleBridgeProps {
|
|
12
|
+
style?: ThemeStyleConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Injects merged theme CSS variables after globals (preset + typed overrides).
|
|
17
|
+
* Renders nothing; safe to mount once under ThemeProvider.
|
|
18
|
+
*/
|
|
19
|
+
export function ThemeStyleBridge({ style }: ThemeStyleBridgeProps) {
|
|
20
|
+
const css = useMemo(() => buildThemeStyleSheet(style), [style]);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof document === 'undefined') return;
|
|
24
|
+
|
|
25
|
+
let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
|
|
26
|
+
if (!css) {
|
|
27
|
+
el?.remove();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!el) {
|
|
32
|
+
el = document.createElement('style');
|
|
33
|
+
el.id = STYLE_ELEMENT_ID;
|
|
34
|
+
document.head.appendChild(el);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
el.textContent = css;
|
|
38
|
+
}, [css]);
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|