@shellui/core 0.2.0-alpha.4 → 0.2.0-beta.1
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/package.json +2 -2
- package/src/app.tsx +2 -2
- package/src/components/ContentView.tsx +70 -135
- package/src/components/LoadingOverlay.tsx +5 -1
- package/src/components/ui/sidebar.tsx +2 -124
- package/src/constants/loading.ts +2 -0
- package/src/features/config/types.ts +2 -0
- package/src/features/layouts/AppLayout.tsx +22 -19
- package/src/features/layouts/LayoutFallback.tsx +8 -0
- package/src/features/layouts/OverlayShell.tsx +23 -9
- package/src/features/layouts/{AppBarLayout.tsx → appbar/AppBarLayout.tsx} +72 -78
- package/src/features/layouts/{FullscreenLayout.tsx → fullscreen/FullscreenLayout.tsx} +5 -11
- package/src/features/layouts/sidebar/BottomNavItem.tsx +88 -0
- package/src/features/layouts/sidebar/MobileBottomNav.tsx +168 -0
- package/src/features/layouts/sidebar/NavigationContent.tsx +159 -0
- package/src/features/layouts/sidebar/SidebarIcons.tsx +93 -0
- package/src/features/layouts/sidebar/SidebarInner.tsx +48 -0
- package/src/features/layouts/sidebar/SidebarLayout.tsx +86 -0
- package/src/features/layouts/sidebar/sidebarUtils.ts +23 -0
- package/src/features/layouts/sidebar/types.ts +8 -0
- package/src/features/layouts/utils.ts +29 -1
- package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
- package/src/features/settings/SettingsView.tsx +178 -181
- package/src/{components → routes/components}/HomeView.tsx +1 -1
- package/src/{components → routes/components}/IndexRoute.tsx +4 -4
- package/src/routes/components/NavigationItemRoute.tsx +19 -0
- package/src/{components → routes/components}/NotFoundView.tsx +9 -4
- package/src/{components → routes/components}/RouteErrorBoundary.tsx +1 -1
- package/src/routes/components/RouteFallback.tsx +8 -0
- package/src/routes/hooks/useNavigationItems.ts +84 -0
- package/src/{router → routes}/routes.tsx +18 -16
- package/src/components/ViewRoute.tsx +0 -48
- package/src/dist/CookiePreferencesView.52b5aec8.js +0 -1182
- package/src/dist/CookiePreferencesView.52b5aec8.js.map +0 -1
- package/src/dist/DefaultLayout.045a82ff.js +0 -1964
- package/src/dist/DefaultLayout.045a82ff.js.map +0 -1
- package/src/dist/DefaultLayout.4454f259.js +0 -4414
- package/src/dist/DefaultLayout.4454f259.js.map +0 -1
- package/src/dist/FullscreenLayout.555c4987.js +0 -1054
- package/src/dist/FullscreenLayout.555c4987.js.map +0 -1
- package/src/dist/HomeView.ddfa7b68.js +0 -771
- package/src/dist/HomeView.ddfa7b68.js.map +0 -1
- package/src/dist/NotFoundView.c75be4f1.js +0 -811
- package/src/dist/NotFoundView.c75be4f1.js.map +0 -1
- package/src/dist/SettingsView.052b03a6.js +0 -4965
- package/src/dist/SettingsView.052b03a6.js.map +0 -1
- package/src/dist/ViewRoute.e6e3b142.js +0 -1042
- package/src/dist/ViewRoute.e6e3b142.js.map +0 -1
- package/src/dist/WindowsLayout.08724167.js +0 -1762
- package/src/dist/WindowsLayout.08724167.js.map +0 -1
- package/src/dist/esm.f0d741e6.js +0 -29520
- package/src/dist/esm.f0d741e6.js.map +0 -1
- package/src/dist/favicon.4367ac1e.svg +0 -14
- package/src/dist/index.parcel.36d65383.js +0 -54089
- package/src/dist/index.parcel.36d65383.js.map +0 -1
- package/src/dist/index.parcel.ca6d8a47.css +0 -3493
- package/src/dist/index.parcel.ca6d8a47.css.map +0 -1
- package/src/dist/index.parcel.html +0 -88
- package/src/features/layouts/DefaultLayout.tsx +0 -660
- package/src/features/layouts/LayoutProviders.tsx +0 -20
- /package/src/{router → routes}/router.tsx +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { shellui } from '@shellui/sdk';
|
|
5
|
+
import type { NavigationItem, NavigationGroup } from '../../config/types';
|
|
6
|
+
import {
|
|
7
|
+
SidebarGroup,
|
|
8
|
+
SidebarGroupLabel,
|
|
9
|
+
SidebarGroupContent,
|
|
10
|
+
SidebarMenu,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
SidebarMenuButton,
|
|
13
|
+
} from '../../../components/ui/sidebar';
|
|
14
|
+
import { cn } from '../../../lib/utils';
|
|
15
|
+
import { getActivePathPrefix, getNavPathPrefix, flattenNavigationItems } from '../utils';
|
|
16
|
+
import { getExternalFaviconUrl } from './sidebarUtils';
|
|
17
|
+
import { ExternalLinkIcon } from './SidebarIcons';
|
|
18
|
+
|
|
19
|
+
export function NavigationContent({
|
|
20
|
+
navigation,
|
|
21
|
+
}: {
|
|
22
|
+
navigation: (NavigationItem | NavigationGroup)[];
|
|
23
|
+
}) {
|
|
24
|
+
const location = useLocation();
|
|
25
|
+
const { i18n } = useTranslation();
|
|
26
|
+
const currentLanguage = i18n.language || 'en';
|
|
27
|
+
|
|
28
|
+
const resolveLocalizedString = (
|
|
29
|
+
value: string | { en: string; fr: string; [key: string]: string },
|
|
30
|
+
lang: string,
|
|
31
|
+
): string => {
|
|
32
|
+
if (typeof value === 'string') return value;
|
|
33
|
+
return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const hasAnyIcons = useMemo(() => {
|
|
37
|
+
return navigation.some((item) => {
|
|
38
|
+
if ('title' in item && 'items' in item) {
|
|
39
|
+
return (item as NavigationGroup).items.some((navItem) => !!navItem.icon);
|
|
40
|
+
}
|
|
41
|
+
return !!(item as NavigationItem).icon;
|
|
42
|
+
});
|
|
43
|
+
}, [navigation]);
|
|
44
|
+
|
|
45
|
+
const flatItems = useMemo(() => flattenNavigationItems(navigation), [navigation]);
|
|
46
|
+
const activePathPrefix = useMemo(
|
|
47
|
+
() => getActivePathPrefix(location.pathname, flatItems),
|
|
48
|
+
[location.pathname, flatItems],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const isGroup = (item: NavigationItem | NavigationGroup): item is NavigationGroup => {
|
|
52
|
+
return 'title' in item && 'items' in item;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const renderNavItem = (navItem: NavigationItem) => {
|
|
56
|
+
const pathPrefix = getNavPathPrefix(navItem);
|
|
57
|
+
const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
|
|
58
|
+
const isExternal = navItem.openIn === 'external';
|
|
59
|
+
const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
60
|
+
const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
|
|
61
|
+
const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
|
|
62
|
+
const iconSrc = navItem.icon ?? faviconUrl ?? null;
|
|
63
|
+
const iconEl = iconSrc ? (
|
|
64
|
+
<img
|
|
65
|
+
src={iconSrc}
|
|
66
|
+
alt=""
|
|
67
|
+
className={cn('h-4 w-4', 'shrink-0')}
|
|
68
|
+
/>
|
|
69
|
+
) : hasAnyIcons ? (
|
|
70
|
+
<span className="h-4 w-4 shrink-0" />
|
|
71
|
+
) : null;
|
|
72
|
+
const externalIcon = isExternal ? (
|
|
73
|
+
<ExternalLinkIcon className="ml-auto h-4 w-4 shrink-0 opacity-70" />
|
|
74
|
+
) : null;
|
|
75
|
+
const content = (
|
|
76
|
+
<>
|
|
77
|
+
{iconEl}
|
|
78
|
+
<span className="truncate">{itemLabel}</span>
|
|
79
|
+
{externalIcon}
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
const linkOrTrigger =
|
|
83
|
+
navItem.openIn === 'modal' ? (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => shellui.openModal(navItem.url)}
|
|
87
|
+
className="flex items-center gap-2 w-full cursor-pointer text-left"
|
|
88
|
+
>
|
|
89
|
+
{content}
|
|
90
|
+
</button>
|
|
91
|
+
) : navItem.openIn === 'drawer' ? (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => shellui.openDrawer({ url: navItem.url, position: navItem.drawerPosition })}
|
|
95
|
+
className="flex items-center gap-2 w-full cursor-pointer text-left"
|
|
96
|
+
>
|
|
97
|
+
{content}
|
|
98
|
+
</button>
|
|
99
|
+
) : navItem.openIn === 'external' ? (
|
|
100
|
+
<a
|
|
101
|
+
href={navItem.url}
|
|
102
|
+
target="_blank"
|
|
103
|
+
rel="noopener noreferrer"
|
|
104
|
+
className="flex items-center gap-2 w-full"
|
|
105
|
+
>
|
|
106
|
+
{content}
|
|
107
|
+
</a>
|
|
108
|
+
) : (
|
|
109
|
+
<Link
|
|
110
|
+
to={pathPrefix}
|
|
111
|
+
className="flex items-center gap-2 w-full"
|
|
112
|
+
>
|
|
113
|
+
{content}
|
|
114
|
+
</Link>
|
|
115
|
+
);
|
|
116
|
+
return (
|
|
117
|
+
<SidebarMenuButton
|
|
118
|
+
asChild
|
|
119
|
+
isActive={isActive}
|
|
120
|
+
className={cn('w-full', isActive && 'bg-sidebar-accent text-sidebar-accent-foreground')}
|
|
121
|
+
>
|
|
122
|
+
{linkOrTrigger}
|
|
123
|
+
</SidebarMenuButton>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{navigation.map((item) => {
|
|
130
|
+
if (isGroup(item)) {
|
|
131
|
+
const groupTitle = resolveLocalizedString(item.title, currentLanguage);
|
|
132
|
+
return (
|
|
133
|
+
<SidebarGroup
|
|
134
|
+
key={groupTitle}
|
|
135
|
+
className="mt-0"
|
|
136
|
+
>
|
|
137
|
+
<SidebarGroupLabel className="mb-1">{groupTitle}</SidebarGroupLabel>
|
|
138
|
+
<SidebarGroupContent>
|
|
139
|
+
<SidebarMenu className="gap-0.5">
|
|
140
|
+
{item.items.map((navItem) => (
|
|
141
|
+
<SidebarMenuItem key={navItem.path}>{renderNavItem(navItem)}</SidebarMenuItem>
|
|
142
|
+
))}
|
|
143
|
+
</SidebarMenu>
|
|
144
|
+
</SidebarGroupContent>
|
|
145
|
+
</SidebarGroup>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return (
|
|
149
|
+
<SidebarMenu
|
|
150
|
+
key={item.path}
|
|
151
|
+
className="gap-0.5"
|
|
152
|
+
>
|
|
153
|
+
<SidebarMenuItem>{renderNavItem(item)}</SidebarMenuItem>
|
|
154
|
+
</SidebarMenu>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { cn } from '../../../lib/utils';
|
|
2
|
+
|
|
3
|
+
/** Inline SVG: external-link icon. Bundled so consumers don't need to serve static SVGs. */
|
|
4
|
+
export function ExternalLinkIcon({ className }: { className?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<svg
|
|
7
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
8
|
+
width="24"
|
|
9
|
+
height="24"
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth="2"
|
|
14
|
+
strokeLinecap="round"
|
|
15
|
+
strokeLinejoin="round"
|
|
16
|
+
className={cn('shrink-0', className)}
|
|
17
|
+
aria-hidden
|
|
18
|
+
>
|
|
19
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
20
|
+
<polyline points="15 3 21 3 21 9" />
|
|
21
|
+
<line
|
|
22
|
+
x1="10"
|
|
23
|
+
y1="14"
|
|
24
|
+
x2="21"
|
|
25
|
+
y2="3"
|
|
26
|
+
/>
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Caret up: expand (show second line). */
|
|
32
|
+
export function CaretUpIcon({ className }: { className?: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<svg
|
|
35
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
36
|
+
width="24"
|
|
37
|
+
height="24"
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
fill="none"
|
|
40
|
+
stroke="currentColor"
|
|
41
|
+
strokeWidth="2"
|
|
42
|
+
strokeLinecap="round"
|
|
43
|
+
strokeLinejoin="round"
|
|
44
|
+
className={cn('shrink-0', className)}
|
|
45
|
+
aria-hidden
|
|
46
|
+
>
|
|
47
|
+
<path d="m18 15-6-6-6 6" />
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Caret down: collapse (hide second line). */
|
|
53
|
+
export function CaretDownIcon({ className }: { className?: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<svg
|
|
56
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
57
|
+
width="24"
|
|
58
|
+
height="24"
|
|
59
|
+
viewBox="0 0 24 24"
|
|
60
|
+
fill="none"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
strokeWidth="2"
|
|
63
|
+
strokeLinecap="round"
|
|
64
|
+
strokeLinejoin="round"
|
|
65
|
+
className={cn('shrink-0', className)}
|
|
66
|
+
aria-hidden
|
|
67
|
+
>
|
|
68
|
+
<path d="m6 9 6 6 6-6" />
|
|
69
|
+
</svg>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Home icon for mobile bottom bar (same as sidebar logo action). */
|
|
74
|
+
export function HomeIcon({ className }: { className?: string }) {
|
|
75
|
+
return (
|
|
76
|
+
<svg
|
|
77
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
78
|
+
width="24"
|
|
79
|
+
height="24"
|
|
80
|
+
viewBox="0 0 24 24"
|
|
81
|
+
fill="none"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
strokeWidth="2"
|
|
84
|
+
strokeLinecap="round"
|
|
85
|
+
strokeLinejoin="round"
|
|
86
|
+
className={cn('shrink-0', className)}
|
|
87
|
+
aria-hidden
|
|
88
|
+
>
|
|
89
|
+
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
90
|
+
<polyline points="9 22 9 12 15 12 15 22" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Link } from 'react-router';
|
|
2
|
+
import type { NavigationItem, NavigationGroup } from '../../config/types';
|
|
3
|
+
import { SidebarHeader, SidebarContent, SidebarFooter } from '../../../components/ui/sidebar';
|
|
4
|
+
import { NavigationContent } from './NavigationContent';
|
|
5
|
+
|
|
6
|
+
/** Reusable sidebar inner: header, main nav, footer. Used in desktop Sidebar and mobile Drawer. */
|
|
7
|
+
export function SidebarInner({
|
|
8
|
+
title,
|
|
9
|
+
logo,
|
|
10
|
+
startNav,
|
|
11
|
+
endItems,
|
|
12
|
+
}: {
|
|
13
|
+
title?: string;
|
|
14
|
+
logo?: string;
|
|
15
|
+
startNav: (NavigationItem | NavigationGroup)[];
|
|
16
|
+
endItems: (NavigationItem | NavigationGroup)[];
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<SidebarHeader className="border-b border-sidebar-border pb-4">
|
|
21
|
+
{(title || logo) && (
|
|
22
|
+
<Link
|
|
23
|
+
to="/"
|
|
24
|
+
className="flex items-center pl-1 pr-3 py-2 text-lg font-semibold text-sidebar-foreground hover:text-sidebar-foreground/80 transition-colors"
|
|
25
|
+
>
|
|
26
|
+
{logo && logo.trim() ? (
|
|
27
|
+
<img
|
|
28
|
+
src={logo}
|
|
29
|
+
alt={title || 'Logo'}
|
|
30
|
+
className="h-5 w-auto shrink-0 object-contain sidebar-logo"
|
|
31
|
+
/>
|
|
32
|
+
) : title ? (
|
|
33
|
+
<span className="leading-none">{title}</span>
|
|
34
|
+
) : null}
|
|
35
|
+
</Link>
|
|
36
|
+
)}
|
|
37
|
+
</SidebarHeader>
|
|
38
|
+
<SidebarContent className="gap-1">
|
|
39
|
+
<NavigationContent navigation={startNav} />
|
|
40
|
+
</SidebarContent>
|
|
41
|
+
{endItems.length > 0 && (
|
|
42
|
+
<SidebarFooter>
|
|
43
|
+
<NavigationContent navigation={endItems} />
|
|
44
|
+
</SidebarFooter>
|
|
45
|
+
)}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Outlet } from 'react-router';
|
|
2
|
+
import { useMemo, useEffect } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Sidebar } from '../../../components/ui/sidebar';
|
|
5
|
+
import { cn } from '../../../lib/utils';
|
|
6
|
+
import {
|
|
7
|
+
filterNavigationByViewport,
|
|
8
|
+
filterNavigationForSidebar,
|
|
9
|
+
flattenNavigationItems,
|
|
10
|
+
resolveLocalizedString as resolveLocalizedLabel,
|
|
11
|
+
splitNavigationByPosition,
|
|
12
|
+
} from '../utils';
|
|
13
|
+
import { SidebarInner } from './SidebarInner';
|
|
14
|
+
import { MobileBottomNav } from './MobileBottomNav';
|
|
15
|
+
import type { SidebarLayoutProps } from './types';
|
|
16
|
+
import { useNavigationItems } from '../../../routes/hooks/useNavigationItems';
|
|
17
|
+
|
|
18
|
+
const SidebarLayoutContent = ({ title, logo, navigation }: SidebarLayoutProps) => {
|
|
19
|
+
const { i18n } = useTranslation();
|
|
20
|
+
const { navigationItem, rootItem } = useNavigationItems();
|
|
21
|
+
|
|
22
|
+
const currentLanguage = useMemo(() => {
|
|
23
|
+
return i18n.language || 'en';
|
|
24
|
+
}, [i18n]);
|
|
25
|
+
|
|
26
|
+
const { startNav, endItems, mobileNavItems } = useMemo(() => {
|
|
27
|
+
const desktopNav = filterNavigationByViewport(navigation, 'desktop');
|
|
28
|
+
const mobileNav = filterNavigationByViewport(navigation, 'mobile');
|
|
29
|
+
const { start, end } = splitNavigationByPosition(desktopNav);
|
|
30
|
+
const mobileFlat = flattenNavigationItems(mobileNav);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
startNav: filterNavigationForSidebar(start),
|
|
34
|
+
endItems: end,
|
|
35
|
+
mobileNavItems: mobileFlat,
|
|
36
|
+
};
|
|
37
|
+
}, [navigation]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!title) return;
|
|
41
|
+
if (navigationItem) {
|
|
42
|
+
const label = resolveLocalizedLabel(navigationItem.label, currentLanguage);
|
|
43
|
+
document.title = `${label} | ${title}`;
|
|
44
|
+
} else {
|
|
45
|
+
document.title = title;
|
|
46
|
+
}
|
|
47
|
+
}, [navigationItem, title, currentLanguage]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div>
|
|
51
|
+
<div className="flex h-screen overflow-hidden">
|
|
52
|
+
<Sidebar className={cn('hidden md:flex shrink-0')}>
|
|
53
|
+
<SidebarInner
|
|
54
|
+
title={title}
|
|
55
|
+
logo={logo}
|
|
56
|
+
startNav={startNav}
|
|
57
|
+
endItems={endItems}
|
|
58
|
+
/>
|
|
59
|
+
</Sidebar>
|
|
60
|
+
|
|
61
|
+
<main className="flex-1 flex flex-col overflow-hidden bg-background relative min-w-0">
|
|
62
|
+
<div className="flex-1 flex flex-col overflow-auto pb-16 md:pb-0">
|
|
63
|
+
<Outlet />
|
|
64
|
+
</div>
|
|
65
|
+
</main>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<MobileBottomNav
|
|
69
|
+
items={mobileNavItems}
|
|
70
|
+
currentLanguage={currentLanguage}
|
|
71
|
+
showHomeButton={!rootItem}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function SidebarLayout({ title, appIcon, logo, navigation }: SidebarLayoutProps) {
|
|
78
|
+
return (
|
|
79
|
+
<SidebarLayoutContent
|
|
80
|
+
title={title}
|
|
81
|
+
appIcon={appIcon}
|
|
82
|
+
logo={logo}
|
|
83
|
+
navigation={navigation}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** DuckDuckGo favicon URL for a given page URL (used when openIn === 'external' and no icon is set). */
|
|
2
|
+
export function getExternalFaviconUrl(url: string): string | null {
|
|
3
|
+
try {
|
|
4
|
+
const parsed = new URL(url);
|
|
5
|
+
const hostname = parsed.hostname;
|
|
6
|
+
if (!hostname) return null;
|
|
7
|
+
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Approximate width per slot (icon + label + padding) and gap for dynamic slot count. */
|
|
14
|
+
export const BOTTOM_NAV_SLOT_WIDTH = 64;
|
|
15
|
+
export const BOTTOM_NAV_GAP = 4;
|
|
16
|
+
export const BOTTOM_NAV_PX = 12;
|
|
17
|
+
/** Max slots in the row (Home + nav + optional More) to avoid overflow/duplicated wrap. */
|
|
18
|
+
export const BOTTOM_NAV_MAX_SLOTS = 6;
|
|
19
|
+
|
|
20
|
+
/** True when the icon is a local app icon (/icons/); external images (avatars, favicons) are shown as-is. */
|
|
21
|
+
export function isAppIcon(src: string): boolean {
|
|
22
|
+
return src.startsWith('/icons/');
|
|
23
|
+
}
|
|
@@ -2,7 +2,35 @@ import type { NavigationItem, NavigationGroup, LocalizedString } from '../config
|
|
|
2
2
|
|
|
3
3
|
/** Path prefix for a nav item: "/" for root (path '' or '/'), otherwise "/{path}". */
|
|
4
4
|
export function getNavPathPrefix(item: NavigationItem): string {
|
|
5
|
-
return item.path === '
|
|
5
|
+
return item.path === '' ? '/' : `/${item.path}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Whether a URL string uses hash-based routing (e.g. contains /#/). */
|
|
9
|
+
export function isHashRouterUrl(url: string): boolean {
|
|
10
|
+
return url.includes('/#/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether a nav item uses hash-based routing (explicit flag or inferred from url). */
|
|
14
|
+
export function isHashRouterNavItem(item: NavigationItem): boolean {
|
|
15
|
+
if (item.useHashRouter === true) return true;
|
|
16
|
+
if (item.useHashRouter === false) return false;
|
|
17
|
+
return isHashRouterUrl(item.url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Base URL without hash (origin + pathname before #). Used to match and build iframe URLs for hash apps. */
|
|
21
|
+
export function getBaseUrlWithoutHash(url: string): string {
|
|
22
|
+
const hashIndex = url.indexOf('#');
|
|
23
|
+
if (hashIndex === -1) return url;
|
|
24
|
+
const base = url.slice(0, hashIndex);
|
|
25
|
+
return base.endsWith('/') ? base : `${base}/`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Hash path from a URL (part after #), e.g. "/themes" from "http://localhost:5173/#/themes". Returns "" if no hash. */
|
|
29
|
+
export function getHashPathFromUrl(url: string): string {
|
|
30
|
+
const hashIndex = url.indexOf('#');
|
|
31
|
+
if (hashIndex === -1) return '';
|
|
32
|
+
const hash = url.slice(hashIndex + 1);
|
|
33
|
+
return hash.startsWith('/') ? hash : `/${hash}`;
|
|
6
34
|
}
|
|
7
35
|
|
|
8
36
|
/** Among items that match the current pathname, return the longest path prefix. Used so only one nav item is active when URLs nest (e.g. /foo and /foo/bar). */
|