create-quadrokit 0.2.12 → 0.2.13
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 +1 -1
- package/templates/admin-shell/src/components/layout/AppShell.tsx +55 -0
- package/templates/admin-shell/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/admin-shell/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/admin-shell/src/components/layout/PageHeading.tsx +40 -0
- package/templates/admin-shell/src/components/layout/Sidebar.tsx +65 -0
- package/templates/admin-shell/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/admin-shell/src/components/layout/TopHeader.tsx +78 -0
- package/templates/admin-shell/src/i18n.ts +16 -2
- package/templates/admin-shell/src/locales/en.json +40 -0
- package/templates/admin-shell/src/locales/fr.json +62 -0
- package/templates/admin-shell/src/main.tsx +8 -2
- package/templates/admin-shell/src/pages/HomePage.tsx +1 -2
- package/templates/admin-shell/src/pages/SampleDataPage.tsx +1 -2
- package/templates/admin-shell/src/router.tsx +13 -4
- package/templates/admin-shell/src/types/router.ts +4 -0
- package/templates/dashboard/src/components/layout/AppShell.tsx +55 -0
- package/templates/dashboard/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/dashboard/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/dashboard/src/components/layout/PageHeading.tsx +40 -0
- package/templates/dashboard/src/components/layout/Sidebar.tsx +65 -0
- package/templates/dashboard/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/dashboard/src/components/layout/TopHeader.tsx +78 -0
- package/templates/dashboard/src/i18n.ts +16 -2
- package/templates/dashboard/src/locales/en.json +40 -0
- package/templates/dashboard/src/locales/fr.json +80 -0
- package/templates/dashboard/src/main.tsx +8 -2
- package/templates/dashboard/src/pages/HomePage.tsx +1 -2
- package/templates/dashboard/src/pages/SampleDataPage.tsx +1 -2
- package/templates/dashboard/src/router.tsx +12 -3
- package/templates/dashboard/src/types/router.ts +4 -0
- package/templates/ecommerce/src/components/layout/AppShell.tsx +55 -0
- package/templates/ecommerce/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/ecommerce/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/ecommerce/src/components/layout/PageHeading.tsx +40 -0
- package/templates/ecommerce/src/components/layout/Sidebar.tsx +65 -0
- package/templates/ecommerce/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/ecommerce/src/components/layout/TopHeader.tsx +78 -0
- package/templates/ecommerce/src/i18n.ts +16 -2
- package/templates/ecommerce/src/locales/en.json +35 -0
- package/templates/ecommerce/src/locales/fr.json +62 -0
- package/templates/ecommerce/src/main.tsx +8 -2
- package/templates/ecommerce/src/pages/HomePage.tsx +1 -2
- package/templates/ecommerce/src/pages/SampleDataPage.tsx +1 -2
- package/templates/ecommerce/src/router.tsx +13 -4
- package/templates/ecommerce/src/types/router.ts +4 -0
- package/templates/website/src/components/layout/AppShell.tsx +55 -0
- package/templates/website/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/website/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/website/src/components/layout/PageHeading.tsx +40 -0
- package/templates/website/src/components/layout/Sidebar.tsx +65 -0
- package/templates/website/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/website/src/components/layout/TopHeader.tsx +78 -0
- package/templates/website/src/i18n.ts +16 -2
- package/templates/website/src/locales/en.json +35 -0
- package/templates/website/src/locales/fr.json +63 -0
- package/templates/website/src/main.tsx +8 -2
- package/templates/website/src/pages/HomePage.tsx +0 -3
- package/templates/website/src/pages/SampleDataPage.tsx +1 -2
- package/templates/website/src/router.tsx +13 -4
- package/templates/website/src/types/router.ts +4 -0
- package/templates/admin-shell/src/components/AppShell.tsx +0 -68
- package/templates/dashboard/src/components/AppShell.tsx +0 -44
- package/templates/ecommerce/src/components/AppShell.tsx +0 -44
- package/templates/website/src/components/AppShell.tsx +0 -44
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { cn } from '@quadrokit/ui'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Outlet } from 'react-router-dom'
|
|
5
|
+
import { PageHeading } from './PageHeading'
|
|
6
|
+
import { Sidebar } from './Sidebar'
|
|
7
|
+
import { TopHeader } from './TopHeader'
|
|
8
|
+
|
|
9
|
+
export function AppShell() {
|
|
10
|
+
const { t } = useTranslation()
|
|
11
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-dvh bg-background text-foreground">
|
|
15
|
+
<div className="hidden w-56 shrink-0 border-r border-border md:block">
|
|
16
|
+
<Sidebar className="sticky top-0 h-dvh" />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
'fixed inset-0 z-50 md:hidden',
|
|
22
|
+
mobileNavOpen ? 'pointer-events-auto' : 'pointer-events-none'
|
|
23
|
+
)}
|
|
24
|
+
aria-hidden={!mobileNavOpen}
|
|
25
|
+
>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className={cn(
|
|
29
|
+
'absolute inset-0 bg-background/80 backdrop-blur-sm transition-opacity',
|
|
30
|
+
mobileNavOpen ? 'opacity-100' : 'opacity-0'
|
|
31
|
+
)}
|
|
32
|
+
aria-label={t('layout.close_menu')}
|
|
33
|
+
tabIndex={mobileNavOpen ? 0 : -1}
|
|
34
|
+
onClick={() => setMobileNavOpen(false)}
|
|
35
|
+
/>
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
'absolute left-0 top-0 h-full w-56 max-w-[85vw] border-r border-border bg-card shadow-lg transition-transform',
|
|
39
|
+
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<Sidebar onNavigate={() => setMobileNavOpen(false)} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
47
|
+
<TopHeader onMenuClick={() => setMobileNavOpen(true)} />
|
|
48
|
+
<main className="flex-1 overflow-auto px-4 py-6 md:px-6 lg:px-8">
|
|
49
|
+
<PageHeading />
|
|
50
|
+
<Outlet />
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Button } from '@quadrokit/ui'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
const LOCALES = [
|
|
5
|
+
{ code: 'en', labelKey: 'layout.lang_en' as const },
|
|
6
|
+
{ code: 'fr', labelKey: 'layout.lang_fr' as const },
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export function LanguageSwitcher() {
|
|
10
|
+
const { i18n, t } = useTranslation()
|
|
11
|
+
const lng = i18n.resolvedLanguage ?? i18n.language
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<fieldset className="m-0 flex min-w-0 items-center gap-1 rounded-md border border-border bg-card p-0.5">
|
|
15
|
+
<legend className="sr-only">{t('layout.language')}</legend>
|
|
16
|
+
{LOCALES.map(({ code, labelKey }) => (
|
|
17
|
+
<Button
|
|
18
|
+
key={code}
|
|
19
|
+
type="button"
|
|
20
|
+
size="sm"
|
|
21
|
+
variant={lng.startsWith(code) ? 'default' : 'ghost'}
|
|
22
|
+
className="h-8 px-2 text-xs"
|
|
23
|
+
onClick={() => {
|
|
24
|
+
void i18n.changeLanguage(code)
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem('quadrokit:lng', code)
|
|
27
|
+
} catch {
|
|
28
|
+
/* ignore */
|
|
29
|
+
}
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{t(labelKey)}
|
|
33
|
+
</Button>
|
|
34
|
+
))}
|
|
35
|
+
</fieldset>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Button, useDismissOnInteractOutside } from '@quadrokit/ui'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
/** Static notification list — replace with real data / subscriptions later. */
|
|
6
|
+
const PLACEHOLDER_ITEMS = [
|
|
7
|
+
{ id: '1', titleKey: 'layout.notif_1' as const, timeKey: 'layout.notif_time_1' as const },
|
|
8
|
+
{ id: '2', titleKey: 'layout.notif_2' as const, timeKey: 'layout.notif_time_2' as const },
|
|
9
|
+
] as const
|
|
10
|
+
|
|
11
|
+
export function NotificationsMenu() {
|
|
12
|
+
const { t } = useTranslation()
|
|
13
|
+
const [open, setOpen] = useState(false)
|
|
14
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
15
|
+
|
|
16
|
+
useDismissOnInteractOutside({
|
|
17
|
+
enabled: open,
|
|
18
|
+
containerRef: rootRef,
|
|
19
|
+
onDismiss: () => setOpen(false),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div ref={rootRef} className="relative">
|
|
24
|
+
<Button
|
|
25
|
+
type="button"
|
|
26
|
+
variant="outline"
|
|
27
|
+
size="icon"
|
|
28
|
+
className="relative h-9 w-9 shrink-0"
|
|
29
|
+
aria-expanded={open}
|
|
30
|
+
aria-haspopup="dialog"
|
|
31
|
+
aria-label={t('layout.notifications')}
|
|
32
|
+
onClick={() => setOpen((o) => !o)}
|
|
33
|
+
>
|
|
34
|
+
<BellIcon className="h-4 w-4" />
|
|
35
|
+
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" aria-hidden />
|
|
36
|
+
</Button>
|
|
37
|
+
{open ? (
|
|
38
|
+
<div
|
|
39
|
+
className="absolute right-0 z-50 mt-1 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-popover p-2 text-sm text-popover-foreground shadow-lg"
|
|
40
|
+
role="dialog"
|
|
41
|
+
aria-label={t('layout.notifications')}
|
|
42
|
+
>
|
|
43
|
+
<p className="border-b border-border px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
44
|
+
{t('layout.notifications')}
|
|
45
|
+
</p>
|
|
46
|
+
<ul className="max-h-64 overflow-auto py-1">
|
|
47
|
+
{PLACEHOLDER_ITEMS.map((item) => (
|
|
48
|
+
<li key={item.id} className="rounded-md px-2 py-2 hover:bg-muted/60">
|
|
49
|
+
<p className="font-medium leading-snug">{t(item.titleKey)}</p>
|
|
50
|
+
<p className="text-xs text-muted-foreground">{t(item.timeKey)}</p>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function BellIcon({ className }: { className?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<svg
|
|
63
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
className={className}
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
focusable="false"
|
|
73
|
+
>
|
|
74
|
+
<title>Notifications</title>
|
|
75
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
76
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
77
|
+
</svg>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Link, useLocation, useMatches } from 'react-router-dom'
|
|
3
|
+
import type { AppRouteHandle } from '@/types/router'
|
|
4
|
+
|
|
5
|
+
export function PageHeading() {
|
|
6
|
+
const { t } = useTranslation()
|
|
7
|
+
const { pathname } = useLocation()
|
|
8
|
+
const matches = useMatches()
|
|
9
|
+
const leaf = matches[matches.length - 1]
|
|
10
|
+
const handle = leaf?.handle as AppRouteHandle | undefined
|
|
11
|
+
|
|
12
|
+
const segmentKey =
|
|
13
|
+
pathname === '/'
|
|
14
|
+
? ('breadcrumb.segment_home' as const)
|
|
15
|
+
: pathname === '/sample-data'
|
|
16
|
+
? ('breadcrumb.segment_sample_data' as const)
|
|
17
|
+
: ('breadcrumb.segment_unknown' as const)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<header className="mb-6">
|
|
21
|
+
<nav
|
|
22
|
+
aria-label={t('layout.breadcrumb_label')}
|
|
23
|
+
className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
|
|
24
|
+
>
|
|
25
|
+
<Link to="/" className="hover:text-foreground">
|
|
26
|
+
{t('breadcrumb.app')}
|
|
27
|
+
</Link>
|
|
28
|
+
<span aria-hidden className="text-border">
|
|
29
|
+
/
|
|
30
|
+
</span>
|
|
31
|
+
<span className="font-medium text-foreground">{t(segmentKey)}</span>
|
|
32
|
+
</nav>
|
|
33
|
+
{handle?.pageTitleKey ? (
|
|
34
|
+
<h1 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
|
35
|
+
{t(handle.pageTitleKey)}
|
|
36
|
+
</h1>
|
|
37
|
+
) : null}
|
|
38
|
+
</header>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cn } from '@quadrokit/ui'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Link, NavLink } from 'react-router-dom'
|
|
4
|
+
|
|
5
|
+
export type SidebarProps = {
|
|
6
|
+
className?: string
|
|
7
|
+
onNavigate?: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Sidebar({ className, onNavigate }: SidebarProps) {
|
|
11
|
+
const { t } = useTranslation()
|
|
12
|
+
|
|
13
|
+
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
14
|
+
cn(
|
|
15
|
+
'rounded-md px-3 py-2 text-sm transition-colors',
|
|
16
|
+
isActive
|
|
17
|
+
? 'bg-primary/15 font-medium text-foreground'
|
|
18
|
+
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<aside className={cn('flex h-full flex-col border-border bg-card/50', className)}>
|
|
23
|
+
<div className="flex h-14 items-center border-b border-border px-4">
|
|
24
|
+
<Link to="/" className="text-lg font-semibold tracking-tight" onClick={onNavigate}>
|
|
25
|
+
{t('app.title')}
|
|
26
|
+
</Link>
|
|
27
|
+
</div>
|
|
28
|
+
<nav className="flex-1 space-y-6 overflow-y-auto p-3" aria-label={t('layout.sidebar_nav')}>
|
|
29
|
+
<div>
|
|
30
|
+
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
31
|
+
{t('layout.nav_section_overview')}
|
|
32
|
+
</p>
|
|
33
|
+
<div className="flex flex-col gap-0.5">
|
|
34
|
+
<NavLink to="/" end className={linkClass} onClick={onNavigate}>
|
|
35
|
+
{t('app.nav_home')}
|
|
36
|
+
</NavLink>
|
|
37
|
+
<NavLink to="/sample-data" className={linkClass} onClick={onNavigate}>
|
|
38
|
+
{t('app.nav_sample_data')}
|
|
39
|
+
</NavLink>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div>
|
|
43
|
+
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
44
|
+
{t('layout.nav_section_apps')}
|
|
45
|
+
</p>
|
|
46
|
+
<p className="px-3 text-sm text-muted-foreground">{t('layout.nav_apps_placeholder')}</p>
|
|
47
|
+
</div>
|
|
48
|
+
</nav>
|
|
49
|
+
<div className="border-t border-border p-3">
|
|
50
|
+
<div className="flex items-center gap-3 rounded-lg border border-border bg-background/80 p-2">
|
|
51
|
+
<div
|
|
52
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/20 text-xs font-semibold text-primary"
|
|
53
|
+
aria-hidden
|
|
54
|
+
>
|
|
55
|
+
{t('layout.user_initials')}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="min-w-0 flex-1">
|
|
58
|
+
<p className="truncate text-sm font-medium leading-tight">{t('layout.user_name')}</p>
|
|
59
|
+
<p className="truncate text-xs text-muted-foreground">{t('layout.user_role')}</p>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</aside>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
type QuadroThemePreset,
|
|
4
|
+
quadroThemes,
|
|
5
|
+
themeLabels,
|
|
6
|
+
useDismissOnInteractOutside,
|
|
7
|
+
useTheme,
|
|
8
|
+
} from '@quadrokit/ui'
|
|
9
|
+
import { useRef, useState } from 'react'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
|
|
12
|
+
/** Compact theme + color mode controls for the header (same behavior as `ThemeToolbar`). */
|
|
13
|
+
export function ThemeMenu() {
|
|
14
|
+
const { t } = useTranslation()
|
|
15
|
+
const { preset, setPreset, colorMode, setColorMode, resolvedMode } = useTheme()
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
18
|
+
|
|
19
|
+
useDismissOnInteractOutside({
|
|
20
|
+
enabled: open,
|
|
21
|
+
containerRef: rootRef,
|
|
22
|
+
onDismiss: () => setOpen(false),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div ref={rootRef} className="relative">
|
|
27
|
+
<Button
|
|
28
|
+
type="button"
|
|
29
|
+
variant="outline"
|
|
30
|
+
className="h-9 px-3"
|
|
31
|
+
aria-expanded={open}
|
|
32
|
+
aria-haspopup="dialog"
|
|
33
|
+
onClick={() => setOpen((o) => !o)}
|
|
34
|
+
>
|
|
35
|
+
{t('layout.theme')}
|
|
36
|
+
</Button>
|
|
37
|
+
{open ? (
|
|
38
|
+
<div
|
|
39
|
+
className="absolute right-0 z-50 mt-1 w-72 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg"
|
|
40
|
+
role="dialog"
|
|
41
|
+
aria-label={t('layout.theme')}
|
|
42
|
+
>
|
|
43
|
+
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
|
44
|
+
{t('layout.theme_presets')}
|
|
45
|
+
</p>
|
|
46
|
+
<div className="flex flex-wrap gap-1">
|
|
47
|
+
{quadroThemes.map((p: QuadroThemePreset) => (
|
|
48
|
+
<Button
|
|
49
|
+
key={p}
|
|
50
|
+
type="button"
|
|
51
|
+
size="sm"
|
|
52
|
+
variant={preset === p ? 'default' : 'outline'}
|
|
53
|
+
onClick={() => setPreset(p)}
|
|
54
|
+
>
|
|
55
|
+
{themeLabels[p]}
|
|
56
|
+
</Button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
<p className="mb-2 mt-3 text-xs font-medium text-muted-foreground">
|
|
60
|
+
{t('layout.theme_mode')}
|
|
61
|
+
</p>
|
|
62
|
+
<div className="flex flex-wrap gap-1">
|
|
63
|
+
{(
|
|
64
|
+
[
|
|
65
|
+
['light', 'layout.mode_light'],
|
|
66
|
+
['dark', 'layout.mode_dark'],
|
|
67
|
+
['system', 'layout.mode_system'],
|
|
68
|
+
] as const
|
|
69
|
+
).map(([m, labelKey]) => (
|
|
70
|
+
<Button
|
|
71
|
+
key={m}
|
|
72
|
+
type="button"
|
|
73
|
+
size="sm"
|
|
74
|
+
variant={colorMode === m ? 'default' : 'ghost'}
|
|
75
|
+
onClick={() => setColorMode(m)}
|
|
76
|
+
>
|
|
77
|
+
{t(labelKey)}
|
|
78
|
+
</Button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
82
|
+
{t('layout.theme_resolved', {
|
|
83
|
+
mode: resolvedMode === 'dark' ? t('layout.mode_dark') : t('layout.mode_light'),
|
|
84
|
+
})}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Button, Input } from '@quadrokit/ui'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { LanguageSwitcher } from './LanguageSwitcher'
|
|
5
|
+
import { NotificationsMenu } from './NotificationsMenu'
|
|
6
|
+
import { ThemeMenu } from './ThemeMenu'
|
|
7
|
+
|
|
8
|
+
export type TopHeaderProps = {
|
|
9
|
+
onMenuClick: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TopHeader({ onMenuClick }: TopHeaderProps) {
|
|
13
|
+
const { t } = useTranslation()
|
|
14
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background/95 px-3 backdrop-blur supports-[backdrop-filter]:bg-background/80 md:px-4">
|
|
18
|
+
<Button
|
|
19
|
+
type="button"
|
|
20
|
+
variant="outline"
|
|
21
|
+
size="icon"
|
|
22
|
+
className="h-9 w-9 md:hidden"
|
|
23
|
+
aria-label={t('layout.open_menu')}
|
|
24
|
+
onClick={onMenuClick}
|
|
25
|
+
>
|
|
26
|
+
<MenuIcon className="h-4 w-4" />
|
|
27
|
+
</Button>
|
|
28
|
+
<div className="min-w-0 flex-1 max-w-md">
|
|
29
|
+
<Input
|
|
30
|
+
type="search"
|
|
31
|
+
placeholder={t('layout.search_placeholder')}
|
|
32
|
+
className="h-9 bg-card"
|
|
33
|
+
aria-label={t('layout.search_placeholder')}
|
|
34
|
+
value={searchQuery}
|
|
35
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
36
|
+
autoComplete="off"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
40
|
+
<LanguageSwitcher />
|
|
41
|
+
<ThemeMenu />
|
|
42
|
+
<NotificationsMenu />
|
|
43
|
+
<div className="hidden items-center gap-2 border-l border-border pl-3 sm:flex">
|
|
44
|
+
<div
|
|
45
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary"
|
|
46
|
+
aria-hidden
|
|
47
|
+
>
|
|
48
|
+
{t('layout.user_initials')}
|
|
49
|
+
</div>
|
|
50
|
+
<div className="hidden leading-tight lg:block">
|
|
51
|
+
<p className="text-sm font-medium">{t('layout.user_name')}</p>
|
|
52
|
+
<p className="text-xs text-muted-foreground">{t('layout.user_role')}</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</header>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function MenuIcon({ className }: { className?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<svg
|
|
63
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
className={className}
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
focusable="false"
|
|
71
|
+
>
|
|
72
|
+
<title>Menu</title>
|
|
73
|
+
<line x1="4" x2="20" y1="6" y2="6" />
|
|
74
|
+
<line x1="4" x2="20" y1="12" y2="12" />
|
|
75
|
+
<line x1="4" x2="20" y1="18" y2="18" />
|
|
76
|
+
</svg>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import i18n from 'i18next'
|
|
2
2
|
import { initReactI18next } from 'react-i18next'
|
|
3
3
|
import en from './locales/en.json'
|
|
4
|
+
import fr from './locales/fr.json'
|
|
5
|
+
|
|
6
|
+
function storedLng(): string | undefined {
|
|
7
|
+
try {
|
|
8
|
+
const v = localStorage.getItem('quadrokit:lng')
|
|
9
|
+
if (v === 'en' || v === 'fr') return v
|
|
10
|
+
} catch {
|
|
11
|
+
/* ignore */
|
|
12
|
+
}
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
void i18n.use(initReactI18next).init({
|
|
6
|
-
resources: {
|
|
7
|
-
|
|
17
|
+
resources: {
|
|
18
|
+
en: { translation: en },
|
|
19
|
+
fr: { translation: fr },
|
|
20
|
+
},
|
|
21
|
+
lng: storedLng() ?? 'en',
|
|
8
22
|
fallbackLng: 'en',
|
|
9
23
|
interpolation: { escapeValue: false },
|
|
10
24
|
})
|
|
@@ -6,6 +6,46 @@
|
|
|
6
6
|
"nav_sample_data": "Sample data",
|
|
7
7
|
"session_cookie": "Expected 4D session cookie: {{name}}"
|
|
8
8
|
},
|
|
9
|
+
"pages": {
|
|
10
|
+
"home": {
|
|
11
|
+
"title": "Business overview"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"breadcrumb": {
|
|
15
|
+
"app": "QuadroKit",
|
|
16
|
+
"segment_home": "Home",
|
|
17
|
+
"segment_sample_data": "Sample data",
|
|
18
|
+
"segment_unknown": "Page"
|
|
19
|
+
},
|
|
20
|
+
"layout": {
|
|
21
|
+
"sidebar_nav": "Main navigation",
|
|
22
|
+
"nav_section_overview": "Overview",
|
|
23
|
+
"nav_section_apps": "Apps",
|
|
24
|
+
"nav_apps_placeholder": "More apps can be linked here.",
|
|
25
|
+
"breadcrumb_label": "Breadcrumb",
|
|
26
|
+
"search_placeholder": "Search…",
|
|
27
|
+
"open_menu": "Open menu",
|
|
28
|
+
"close_menu": "Close menu",
|
|
29
|
+
"close": "Close",
|
|
30
|
+
"language": "Language",
|
|
31
|
+
"lang_en": "EN",
|
|
32
|
+
"lang_fr": "FR",
|
|
33
|
+
"theme": "Theme",
|
|
34
|
+
"theme_presets": "Presets",
|
|
35
|
+
"theme_mode": "Mode",
|
|
36
|
+
"mode_light": "Light",
|
|
37
|
+
"mode_dark": "Dark",
|
|
38
|
+
"mode_system": "System",
|
|
39
|
+
"theme_resolved": "Active: {{mode}}",
|
|
40
|
+
"notifications": "Notifications",
|
|
41
|
+
"notif_1": "New deployment finished successfully.",
|
|
42
|
+
"notif_2": "A comment was added to your report.",
|
|
43
|
+
"notif_time_1": "2 min ago",
|
|
44
|
+
"notif_time_2": "1 hour ago",
|
|
45
|
+
"user_name": "Denish N",
|
|
46
|
+
"user_role": "Team",
|
|
47
|
+
"user_initials": "DN"
|
|
48
|
+
},
|
|
9
49
|
"sample_data": {
|
|
10
50
|
"title": "Sample data",
|
|
11
51
|
"intro": "This page uses static placeholder rows. It does not assume any particular 4D table.",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": {
|
|
3
|
+
"title": "QuadroKit",
|
|
4
|
+
"tagline": "Coque admin avec barre latérale — même stack, mise en page plus dense.",
|
|
5
|
+
"nav_home": "Accueil",
|
|
6
|
+
"nav_sample_data": "Données d'exemple",
|
|
7
|
+
"session_cookie": "Cookie de session 4D attendu : {{name}}"
|
|
8
|
+
},
|
|
9
|
+
"pages": {
|
|
10
|
+
"home": {
|
|
11
|
+
"title": "Vue d'ensemble"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"breadcrumb": {
|
|
15
|
+
"app": "QuadroKit",
|
|
16
|
+
"segment_home": "Accueil",
|
|
17
|
+
"segment_sample_data": "Données d'exemple",
|
|
18
|
+
"segment_unknown": "Page"
|
|
19
|
+
},
|
|
20
|
+
"layout": {
|
|
21
|
+
"sidebar_nav": "Navigation principale",
|
|
22
|
+
"nav_section_overview": "Vue d'ensemble",
|
|
23
|
+
"nav_section_apps": "Applications",
|
|
24
|
+
"nav_apps_placeholder": "D'autres applications peuvent être ajoutées ici.",
|
|
25
|
+
"breadcrumb_label": "Fil d'Ariane",
|
|
26
|
+
"search_placeholder": "Rechercher…",
|
|
27
|
+
"open_menu": "Ouvrir le menu",
|
|
28
|
+
"close_menu": "Fermer le menu",
|
|
29
|
+
"close": "Fermer",
|
|
30
|
+
"language": "Langue",
|
|
31
|
+
"lang_en": "EN",
|
|
32
|
+
"lang_fr": "FR",
|
|
33
|
+
"theme": "Thème",
|
|
34
|
+
"theme_presets": "Préréglages",
|
|
35
|
+
"theme_mode": "Mode",
|
|
36
|
+
"mode_light": "Clair",
|
|
37
|
+
"mode_dark": "Sombre",
|
|
38
|
+
"mode_system": "Système",
|
|
39
|
+
"theme_resolved": "Actif : {{mode}}",
|
|
40
|
+
"notifications": "Notifications",
|
|
41
|
+
"notif_1": "Nouveau déploiement terminé avec succès.",
|
|
42
|
+
"notif_2": "Un commentaire a été ajouté à votre rapport.",
|
|
43
|
+
"notif_time_1": "Il y a 2 min",
|
|
44
|
+
"notif_time_2": "Il y a 1 h",
|
|
45
|
+
"user_name": "Denish N",
|
|
46
|
+
"user_role": "Équipe",
|
|
47
|
+
"user_initials": "DN"
|
|
48
|
+
},
|
|
49
|
+
"sample_data": {
|
|
50
|
+
"title": "Données d'exemple",
|
|
51
|
+
"intro": "Cette page utilise des lignes statiques. Elle ne suppose aucune table 4D particulière.",
|
|
52
|
+
"hint": "Après `quadrokit-client generate`, demandez à votre agent de charger les bonnes entités depuis votre catalogue (par ex. `quadro.<Entité>.all({ ... })`).",
|
|
53
|
+
"card_title": "Lignes d'exemple",
|
|
54
|
+
"count": "{{count}} lignes d'exemple"
|
|
55
|
+
},
|
|
56
|
+
"form": {
|
|
57
|
+
"demo_title": "react-hook-form + zod",
|
|
58
|
+
"label": "Libellé",
|
|
59
|
+
"placeholder": "Saisissez quelque chose…",
|
|
60
|
+
"submit": "Envoyer"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StrictMode } from 'react'
|
|
1
|
+
import { StrictMode, Suspense } from 'react'
|
|
2
2
|
import { createRoot } from 'react-dom/client'
|
|
3
3
|
import { RouterProvider } from 'react-router-dom'
|
|
4
4
|
import '@quadrokit/ui/styles.css'
|
|
@@ -9,7 +9,13 @@ import { router } from './router'
|
|
|
9
9
|
createRoot(document.getElementById('root')!).render(
|
|
10
10
|
<StrictMode>
|
|
11
11
|
<ThemeProvider>
|
|
12
|
-
<
|
|
12
|
+
<Suspense
|
|
13
|
+
fallback={
|
|
14
|
+
<div className="flex min-h-dvh items-center justify-center text-muted-foreground">…</div>
|
|
15
|
+
}
|
|
16
|
+
>
|
|
17
|
+
<RouterProvider router={router} />
|
|
18
|
+
</Suspense>
|
|
13
19
|
</ThemeProvider>
|
|
14
20
|
</StrictMode>
|
|
15
21
|
)
|
|
@@ -34,8 +34,7 @@ export function HomePage() {
|
|
|
34
34
|
return (
|
|
35
35
|
<div className="space-y-8">
|
|
36
36
|
<div>
|
|
37
|
-
<
|
|
38
|
-
<p className="mt-2 max-w-2xl text-muted-foreground">{t('app.tagline')}</p>
|
|
37
|
+
<p className="max-w-2xl text-muted-foreground">{t('app.tagline')}</p>
|
|
39
38
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
40
39
|
{t('app.session_cookie', { name: quadro.sessionCookieName })}
|
|
41
40
|
</p>
|
|
@@ -14,8 +14,7 @@ export function SampleDataPage() {
|
|
|
14
14
|
return (
|
|
15
15
|
<div className="space-y-6">
|
|
16
16
|
<div>
|
|
17
|
-
<
|
|
18
|
-
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{t('sample_data.intro')}</p>
|
|
17
|
+
<p className="max-w-2xl text-sm text-muted-foreground">{t('sample_data.intro')}</p>
|
|
19
18
|
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{t('sample_data.hint')}</p>
|
|
20
19
|
</div>
|
|
21
20
|
<Card>
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { createBrowserRouter } from 'react-router-dom'
|
|
2
|
-
import { AppShell } from './components/AppShell'
|
|
3
|
-
import { SampleDataPage } from './pages/SampleDataPage'
|
|
2
|
+
import { AppShell } from './components/layout/AppShell'
|
|
4
3
|
import { HomePage } from './pages/HomePage'
|
|
4
|
+
import { SampleDataPage } from './pages/SampleDataPage'
|
|
5
|
+
import type { AppRouteHandle } from './types/router'
|
|
5
6
|
|
|
6
7
|
export const router = createBrowserRouter([
|
|
7
8
|
{
|
|
8
9
|
path: '/',
|
|
9
10
|
element: <AppShell />,
|
|
10
11
|
children: [
|
|
11
|
-
{
|
|
12
|
-
|
|
12
|
+
{
|
|
13
|
+
index: true,
|
|
14
|
+
element: <HomePage />,
|
|
15
|
+
handle: { pageTitleKey: 'pages.home.title' } satisfies AppRouteHandle,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
path: 'sample-data',
|
|
19
|
+
element: <SampleDataPage />,
|
|
20
|
+
handle: { pageTitleKey: 'sample_data.title' } satisfies AppRouteHandle,
|
|
21
|
+
},
|
|
13
22
|
],
|
|
14
23
|
},
|
|
15
24
|
])
|