@owlmeans/web-panel 0.1.18-rc.2 → 0.1.18-rc.21
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 +85 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-panel/SKILL.md +157 -4
- package/build/@/components/ui/navigation-menu.d.ts +15 -0
- package/build/@/components/ui/navigation-menu.d.ts.map +1 -0
- package/build/@/components/ui/navigation-menu.js +32 -0
- package/build/@/components/ui/navigation-menu.js.map +1 -0
- package/build/auth/plugins/basic-ed25519.js +1 -1
- package/build/auth/plugins/basic-ed25519.js.map +1 -1
- package/build/components/footer/component.d.ts +12 -0
- package/build/components/footer/component.d.ts.map +1 -0
- package/build/components/footer/component.js +24 -0
- package/build/components/footer/component.js.map +1 -0
- package/build/components/footer/index.d.ts +3 -0
- package/build/components/footer/index.d.ts.map +1 -0
- package/build/components/footer/index.js +2 -0
- package/build/components/footer/index.js.map +1 -0
- package/build/components/footer/types.d.ts +11 -0
- package/build/components/footer/types.d.ts.map +1 -0
- package/build/components/footer/types.js +2 -0
- package/build/components/footer/types.js.map +1 -0
- package/build/components/index.d.ts +4 -0
- package/build/components/index.d.ts.map +1 -1
- package/build/components/index.js +4 -0
- package/build/components/index.js.map +1 -1
- package/build/components/login/append.d.ts +22 -0
- package/build/components/login/append.d.ts.map +1 -0
- package/build/components/login/append.js +21 -0
- package/build/components/login/append.js.map +1 -0
- package/build/components/login/credit.d.ts +17 -0
- package/build/components/login/credit.d.ts.map +1 -0
- package/build/components/login/credit.js +17 -0
- package/build/components/login/credit.js.map +1 -0
- package/build/components/login/icons.d.ts +14 -0
- package/build/components/login/icons.d.ts.map +1 -0
- package/build/components/login/icons.js +29 -0
- package/build/components/login/icons.js.map +1 -0
- package/build/components/login/index.d.ts +6 -0
- package/build/components/login/index.d.ts.map +1 -0
- package/build/components/login/index.js +6 -0
- package/build/components/login/index.js.map +1 -0
- package/build/components/login/screen.d.ts +23 -0
- package/build/components/login/screen.d.ts.map +1 -0
- package/build/components/login/screen.js +54 -0
- package/build/components/login/screen.js.map +1 -0
- package/build/components/login/terms.d.ts +9 -0
- package/build/components/login/terms.d.ts.map +1 -0
- package/build/components/login/terms.js +30 -0
- package/build/components/login/terms.js.map +1 -0
- package/build/components/nav/index.d.ts +5 -0
- package/build/components/nav/index.d.ts.map +1 -0
- package/build/components/nav/index.js +4 -0
- package/build/components/nav/index.js.map +1 -0
- package/build/components/nav/layout.d.ts +12 -0
- package/build/components/nav/layout.d.ts.map +1 -0
- package/build/components/nav/layout.js +40 -0
- package/build/components/nav/layout.js.map +1 -0
- package/build/components/nav/side.d.ts +11 -0
- package/build/components/nav/side.d.ts.map +1 -0
- package/build/components/nav/side.js +25 -0
- package/build/components/nav/side.js.map +1 -0
- package/build/components/nav/top.d.ts +10 -0
- package/build/components/nav/top.d.ts.map +1 -0
- package/build/components/nav/top.js +44 -0
- package/build/components/nav/top.js.map +1 -0
- package/build/components/nav/types.d.ts +55 -0
- package/build/components/nav/types.d.ts.map +1 -0
- package/build/components/nav/types.js +2 -0
- package/build/components/nav/types.js.map +1 -0
- package/build/components/toaster.d.ts +16 -0
- package/build/components/toaster.d.ts.map +1 -0
- package/build/components/toaster.js +52 -0
- package/build/components/toaster.js.map +1 -0
- package/build/consent/component.d.ts +11 -0
- package/build/consent/component.d.ts.map +1 -0
- package/build/consent/component.js +53 -0
- package/build/consent/component.js.map +1 -0
- package/build/consent/index.d.ts +4 -0
- package/build/consent/index.d.ts.map +1 -0
- package/build/consent/index.js +3 -0
- package/build/consent/index.js.map +1 -0
- package/build/context.d.ts.map +1 -1
- package/build/context.js +4 -0
- package/build/context.js.map +1 -1
- package/package.json +38 -27
- package/src/@/components/ui/navigation-menu.tsx +169 -0
- package/src/auth/plugins/basic-ed25519.tsx +1 -1
- package/src/components/footer/component.tsx +37 -0
- package/src/components/footer/index.ts +3 -0
- package/src/components/footer/types.ts +11 -0
- package/src/components/index.ts +4 -0
- package/src/components/login/append.ts +34 -0
- package/src/components/login/credit.tsx +34 -0
- package/src/components/login/icons.tsx +39 -0
- package/src/components/login/index.ts +5 -0
- package/src/components/login/screen.tsx +110 -0
- package/src/components/login/terms.tsx +71 -0
- package/src/components/nav/index.ts +5 -0
- package/src/components/nav/layout.tsx +85 -0
- package/src/components/nav/side.tsx +52 -0
- package/src/components/nav/top.tsx +62 -0
- package/src/components/nav/types.ts +56 -0
- package/src/components/toaster.tsx +68 -0
- package/src/consent/component.tsx +62 -0
- package/src/consent/index.ts +11 -0
- package/src/context.ts +5 -0
- package/tests/context.ts +43 -0
- package/tests/harness/index.html +11 -0
- package/tests/harness/mount.tsx +183 -0
- package/tests/login.spec.ts +115 -0
- package/tests/nav.spec.ts +252 -0
- package/tests/smoke.spec.ts +5 -1
- package/tests/toaster.spec.ts +59 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isValidElement } from 'react'
|
|
2
|
+
import type { FC } from 'react'
|
|
3
|
+
import { Loader2 } from 'lucide-react'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
import { useI18nLib } from '@owlmeans/client-i18n'
|
|
8
|
+
import { useLoginMethods } from '@owlmeans/client-panel/auth'
|
|
9
|
+
import type { LoginMethod, LoginScreenProps } from '@owlmeans/client-auth/login'
|
|
10
|
+
import { LoginMethodIcon } from './icons.js'
|
|
11
|
+
import { LoginTerms } from './terms.js'
|
|
12
|
+
import { LoginCredit } from './credit.js'
|
|
13
|
+
|
|
14
|
+
const VARIANT: Record<string, 'default' | 'outline' | 'link'> = {
|
|
15
|
+
primary: 'default',
|
|
16
|
+
secondary: 'outline',
|
|
17
|
+
link: 'link',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The sign-in screen: which identity provider, confirmed against which documents.
|
|
22
|
+
*
|
|
23
|
+
* It never starts anything by itself. Every flow leaves from a click — which is the requirement,
|
|
24
|
+
* and also what makes framed sign-in work at all, because the window a framed application has to
|
|
25
|
+
* open can only be opened inside a user gesture.
|
|
26
|
+
*
|
|
27
|
+
* Pure with respect to i18n: `translate` is a prop. {@link LocalizedLoginScreen} is the wrapper
|
|
28
|
+
* that binds it to the app's own resources, so an application mounted without an i18n provider can
|
|
29
|
+
* still render this one with a resolver of its own.
|
|
30
|
+
*/
|
|
31
|
+
export const LoginScreen: FC<LoginScreenProps> = props => {
|
|
32
|
+
const t = props.translate ?? ((_key: string, defaultValue: string) => defaultValue)
|
|
33
|
+
const model = useLoginMethods({
|
|
34
|
+
...(props.config != null ? { config: props.config } : {}),
|
|
35
|
+
...(props.terms !== undefined ? { terms: props.terms } : {}),
|
|
36
|
+
...(props.methods != null ? { methods: props.methods } : {}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const Logo = props.Logo
|
|
40
|
+
|
|
41
|
+
return <div className={cn('flex min-h-full w-full items-center justify-center p-4', props.className)}>
|
|
42
|
+
<Card className={cn('w-full max-w-sm', props.containerClassName)}>
|
|
43
|
+
<CardHeader className="items-center gap-2 text-center">
|
|
44
|
+
{Logo != null && <div className="flex justify-center pb-2">
|
|
45
|
+
{isValidElement(Logo) ? Logo : typeof Logo === 'function'
|
|
46
|
+
? <Logo className="h-10 w-auto" /> : Logo}
|
|
47
|
+
</div>}
|
|
48
|
+
<CardTitle className="text-xl">{props.title ?? t('login.title', 'Sign in')}</CardTitle>
|
|
49
|
+
<CardDescription>
|
|
50
|
+
{props.subtitle ?? t('login.subtitle', 'Choose how you would like to continue.')}
|
|
51
|
+
</CardDescription>
|
|
52
|
+
</CardHeader>
|
|
53
|
+
|
|
54
|
+
<CardContent className="flex flex-col gap-4">
|
|
55
|
+
{model.methods.length < 1
|
|
56
|
+
? <p role="status" className="text-sm text-muted-foreground text-center">
|
|
57
|
+
{t('login.empty', 'No sign-in method is configured for this application.')}
|
|
58
|
+
</p>
|
|
59
|
+
: <div className="flex flex-col gap-2">
|
|
60
|
+
{model.methods.map((method: LoginMethod) => <Button
|
|
61
|
+
key={method.id}
|
|
62
|
+
type="button"
|
|
63
|
+
data-login-method={method.id}
|
|
64
|
+
variant={VARIANT[method.emphasis ?? 'secondary'] ?? 'outline'}
|
|
65
|
+
// `aria-disabled`, never `disabled`. A disabled button swallows the click, so a user
|
|
66
|
+
// who has not confirmed the terms would press it and be told nothing at all — the
|
|
67
|
+
// screen would simply seem broken. Blocking happens in the handler, which then says
|
|
68
|
+
// why.
|
|
69
|
+
aria-disabled={model.blocked}
|
|
70
|
+
data-blocked={model.blocked ? 'true' : undefined}
|
|
71
|
+
className={cn('w-full justify-center gap-2', model.blocked && 'opacity-60')}
|
|
72
|
+
autoFocus={method.id === model.primary?.id}
|
|
73
|
+
onClick={() => model.select(method)}
|
|
74
|
+
>
|
|
75
|
+
{model.busy === method.id
|
|
76
|
+
? <Loader2 className="size-4 animate-spin" />
|
|
77
|
+
: <LoginMethodIcon name={method.icon} className="size-4" />}
|
|
78
|
+
{method.label ?? t(`login.method.${method.i18nKey ?? method.id}`, method.id)}
|
|
79
|
+
</Button>)}
|
|
80
|
+
</div>}
|
|
81
|
+
|
|
82
|
+
{model.terms.required && <LoginTerms model={model.terms} translate={t} />}
|
|
83
|
+
|
|
84
|
+
{model.error != null && <p role="alert" className="text-sm text-destructive">
|
|
85
|
+
{model.error}
|
|
86
|
+
</p>}
|
|
87
|
+
</CardContent>
|
|
88
|
+
|
|
89
|
+
<div className="px-6">
|
|
90
|
+
{props.footer ?? <LoginCredit model={model.credit} translate={t} />}
|
|
91
|
+
</div>
|
|
92
|
+
</Card>
|
|
93
|
+
</div>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* {@link LoginScreen} bound to the application's own translations.
|
|
98
|
+
*
|
|
99
|
+
* The split exists because the house rule is that a reusable component takes `translate` as a prop
|
|
100
|
+
* and never reaches for a context — but the ordinary case is an app that has one, and making every
|
|
101
|
+
* app write the resolver would guarantee some of them ship English.
|
|
102
|
+
*/
|
|
103
|
+
export const LocalizedLoginScreen: FC<LoginScreenProps> = props => {
|
|
104
|
+
const t = useI18nLib('auth')
|
|
105
|
+
|
|
106
|
+
return <LoginScreen
|
|
107
|
+
{...props}
|
|
108
|
+
translate={props.translate ?? ((key, defaultValue) => t(key, { defaultValue }))}
|
|
109
|
+
/>
|
|
110
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FC, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import type { LoginTermsModel } from '@owlmeans/client-panel/auth'
|
|
4
|
+
|
|
5
|
+
export interface LoginTermsProps {
|
|
6
|
+
model: LoginTermsModel
|
|
7
|
+
translate: (key: string, defaultValue: string) => string
|
|
8
|
+
className?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Split a translated sentence around its two link placeholders.
|
|
13
|
+
*
|
|
14
|
+
* The sentence is translated as ONE string with `{{terms}}` and `{{privacy}}` in it, rather than
|
|
15
|
+
* assembled from fragments, because word order differs between languages and an assembled sentence
|
|
16
|
+
* forces every translator into English's. Splitting the already-translated string keeps that
|
|
17
|
+
* freedom while keeping the i18n contract at `(key, defaultValue) => string` — no ReactNode ever
|
|
18
|
+
* comes out of a translation, so an app with no i18n provider still renders a correct sentence.
|
|
19
|
+
*/
|
|
20
|
+
const interpolate = (
|
|
21
|
+
sentence: string, slots: Record<string, ReactNode>
|
|
22
|
+
): ReactNode[] => sentence
|
|
23
|
+
.split(/(\{\{[a-z]+\}\})/i)
|
|
24
|
+
.map((part, index) => {
|
|
25
|
+
const match = /^\{\{([a-z]+)\}\}$/i.exec(part)
|
|
26
|
+
|
|
27
|
+
return match != null
|
|
28
|
+
? <span key={index}>{slots[match[1]] ?? part}</span>
|
|
29
|
+
: <span key={index}>{part}</span>
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const LoginTerms: FC<LoginTermsProps> = ({ model, translate, className }) => {
|
|
33
|
+
const link = (href: string, label: string): ReactNode =>
|
|
34
|
+
<a href={href} target="_blank" rel="noreferrer noopener"
|
|
35
|
+
className="underline underline-offset-2 hover:text-foreground">{label}</a>
|
|
36
|
+
|
|
37
|
+
return <div className={cn('flex flex-col gap-1.5', className)}>
|
|
38
|
+
<label className="flex items-start gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
39
|
+
{/*
|
|
40
|
+
A NATIVE checkbox, deliberately. `web-panel` ships no `checkbox` primitive, and requiring
|
|
41
|
+
every consumer to vendor one plus its Radix peer to render a sign-in screen would be a
|
|
42
|
+
breaking change for every application already on this package. A native control is also the
|
|
43
|
+
most accessible thing available here.
|
|
44
|
+
*/}
|
|
45
|
+
<input
|
|
46
|
+
type="checkbox" data-login-terms
|
|
47
|
+
className="mt-0.5 size-4 shrink-0 accent-primary"
|
|
48
|
+
checked={model.accepted}
|
|
49
|
+
aria-invalid={model.attempted && !model.accepted}
|
|
50
|
+
onChange={event => model.accept(event.target.checked)}
|
|
51
|
+
/>
|
|
52
|
+
<span>
|
|
53
|
+
{interpolate(
|
|
54
|
+
translate('login.terms.agreement',
|
|
55
|
+
'I have read and agree to the {{terms}} and the {{privacy}}.'),
|
|
56
|
+
{
|
|
57
|
+
terms: link(model.urls.terms, translate('login.terms.terms', 'Terms & Conditions')),
|
|
58
|
+
privacy: link(model.urls.privacy, translate('login.terms.privacy', 'Privacy Policy')),
|
|
59
|
+
cookies: model.urls.cookies != null
|
|
60
|
+
? link(model.urls.cookies, translate('login.terms.cookies', 'Cookie Policy'))
|
|
61
|
+
: null,
|
|
62
|
+
}
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
</label>
|
|
66
|
+
{model.attempted && !model.accepted && <p role="alert" className="text-sm text-destructive">
|
|
67
|
+
{translate('login.terms.required',
|
|
68
|
+
'Please confirm the Terms & Conditions and the Privacy Policy to continue.')}
|
|
69
|
+
</p>}
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { useNavigate } from '@owlmeans/client'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Footer } from '../footer/component.js'
|
|
5
|
+
|
|
6
|
+
import { SideNav } from './side.js'
|
|
7
|
+
import { TopNav } from './top.js'
|
|
8
|
+
import type { NavLayoutProps } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The horizontal rhythm of the whole page, applied identically to the header row, the content
|
|
12
|
+
* and the footer row.
|
|
13
|
+
*
|
|
14
|
+
* It lives in ONE constant because the three regions have to agree: a content area with its own
|
|
15
|
+
* width sits visibly inset from a full-width header, which reads as a mistake rather than as a
|
|
16
|
+
* design. Adjust it through `containerClassName`, which is MERGED over this — never by giving
|
|
17
|
+
* the content a width of its own.
|
|
18
|
+
*/
|
|
19
|
+
const CONTAINER = 'mx-auto w-full max-w-6xl px-4'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The standard two-layer application shell.
|
|
23
|
+
*
|
|
24
|
+
* A layout entrypoint elevates a component that renders this and nothing else — the screen
|
|
25
|
+
* arrives as `children`. The side menu is mounted twice on purpose: one column for wide
|
|
26
|
+
* viewports and one strip for narrow ones. Both render only when the active section has
|
|
27
|
+
* more than one screen, so the single-screen case costs nothing but the elements' absence.
|
|
28
|
+
*/
|
|
29
|
+
export const NavLayout: FC<NavLayoutProps> = ({
|
|
30
|
+
nav, translate, title, home, actions, footer, children, className, style,
|
|
31
|
+
headerClassName, contentClassName, containerClassName
|
|
32
|
+
}) => {
|
|
33
|
+
const navigator = useNavigate()
|
|
34
|
+
const brandAlias = home ?? nav.sections.find(section => section.items.length > 0)?.items[0]?.alias
|
|
35
|
+
// MERGED over the default, never substituted for it. `containerClassName` is how a design
|
|
36
|
+
// adjusts ONE aspect of the rhythm — almost always the width — and a caller passing
|
|
37
|
+
// `max-w-[1280px]` means "wider", not "no padding and no centring". Substituting dropped
|
|
38
|
+
// `px-4` and `mx-auto` along with the width it replaced, which is a page whose header,
|
|
39
|
+
// content and footer all run flush to the window edge. tailwind-merge keeps the override
|
|
40
|
+
// winning on the utility it names and leaves the rest of the rhythm standing, so a width-only
|
|
41
|
+
// value stays a width-only change; `px-8` still overrides the padding when that is the intent.
|
|
42
|
+
const container = cn(CONTAINER, containerClassName)
|
|
43
|
+
|
|
44
|
+
return <div
|
|
45
|
+
className={cn('flex min-h-screen flex-col bg-background text-foreground', className)}
|
|
46
|
+
style={style}
|
|
47
|
+
>
|
|
48
|
+
{/*
|
|
49
|
+
* The header is a SURFACE, and it states both halves of one.
|
|
50
|
+
*
|
|
51
|
+
* It has to paint an opaque background — it is sticky, and content scrolls underneath —
|
|
52
|
+
* which makes it a different surface from the root behind it. A colour set on the root
|
|
53
|
+
* (`className="bg-secondary text-secondary-foreground"`, a dark application shell) then
|
|
54
|
+
* inherits INTO this bar while its own `bg-background` stays put, and every child that
|
|
55
|
+
* states no colour of its own — the brand, a ghost-variant action button — is painted in
|
|
56
|
+
* the foreground of a surface it is not on. That is light-on-light, it raises nothing at
|
|
57
|
+
* build or run time, and it is invisible only to whoever opens the page.
|
|
58
|
+
*
|
|
59
|
+
* `text-foreground` is what stops the inheritance at the boundary. It is not decoration
|
|
60
|
+
* and it is not redundant with the root: pairing has to be restated by every element that
|
|
61
|
+
* repaints its own background. `headerClassName` lands after it, so an app that wants a
|
|
62
|
+
* dark bar overrides BOTH halves through tailwind-merge.
|
|
63
|
+
*/}
|
|
64
|
+
<header className={cn('sticky top-0 z-40 border-b bg-background text-foreground', headerClassName)}>
|
|
65
|
+
<div className={cn('flex h-14 items-center gap-6', container)}>
|
|
66
|
+
{title != null ? <a
|
|
67
|
+
onClick={brandAlias != null ? navigator.press(brandAlias) : undefined}
|
|
68
|
+
className={cn('brand flex items-center gap-2 text-lg font-semibold', brandAlias != null && 'cursor-pointer')}
|
|
69
|
+
>{title}</a> : null}
|
|
70
|
+
<TopNav config={nav} translate={translate} ariaLabel="Sections" />
|
|
71
|
+
<div className="ml-auto flex items-center gap-2">{actions}</div>
|
|
72
|
+
</div>
|
|
73
|
+
<SideNav config={nav} translate={translate} variant="bar" ariaLabel="Screens" className="md:hidden" />
|
|
74
|
+
</header>
|
|
75
|
+
<div className="flex flex-1">
|
|
76
|
+
<SideNav config={nav} translate={translate} variant="side" ariaLabel="Screens" className="hidden md:block" />
|
|
77
|
+
<main className={cn('flex-1 py-8', contentClassName)}>
|
|
78
|
+
<div className={cn(container)}>{children}</div>
|
|
79
|
+
</main>
|
|
80
|
+
</div>
|
|
81
|
+
{Array.isArray(footer)
|
|
82
|
+
? <Footer links={footer} translate={translate} containerClassName={container} />
|
|
83
|
+
: footer != null ? <Footer containerClassName={container}>{footer}</Footer> : null}
|
|
84
|
+
</div>
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { defaultNavTranslate, resolveNavLabel, usePanelNav } from '@owlmeans/client-panel'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
|
|
6
|
+
import type { SideNavProps } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The second navigation level — the screens of the active section.
|
|
10
|
+
*
|
|
11
|
+
* It renders NOTHING when the section holds a single screen: a menu offering the page you
|
|
12
|
+
* are already on is noise. The component still exists in the tree, so a section that grows
|
|
13
|
+
* a second screen gains its side menu with no layout change.
|
|
14
|
+
*/
|
|
15
|
+
export const SideNav: FC<SideNavProps> = (
|
|
16
|
+
{ config, translate = defaultNavTranslate, variant = 'side', ariaLabel, className, style }
|
|
17
|
+
) => {
|
|
18
|
+
const model = usePanelNav(config)
|
|
19
|
+
|
|
20
|
+
if (!model.showSide || model.active == null) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const items = model.active.items.map(item => {
|
|
25
|
+
const label = resolveNavLabel(translate, item.label, `modules.${item.alias}`, item.alias)
|
|
26
|
+
|
|
27
|
+
return <Button
|
|
28
|
+
key={item.alias}
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size={variant === 'bar' ? 'sm' : 'default'}
|
|
31
|
+
onClick={model.goItem(item)}
|
|
32
|
+
aria-current={model.isItemActive(item) ? 'page' : undefined}
|
|
33
|
+
className={cn(
|
|
34
|
+
variant === 'bar' ? 'shrink-0' : 'w-full justify-start',
|
|
35
|
+
model.isItemActive(item) && 'bg-accent text-accent-foreground'
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{item.Icon != null ? <item.Icon className="size-4" /> : null}
|
|
39
|
+
{label}
|
|
40
|
+
</Button>
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return variant === 'bar'
|
|
44
|
+
? <nav
|
|
45
|
+
aria-label={ariaLabel}
|
|
46
|
+
className={cn('flex gap-1 overflow-x-auto border-b px-2 py-1', className)}
|
|
47
|
+
style={style}
|
|
48
|
+
>{items}</nav>
|
|
49
|
+
: <aside className={cn('w-56 shrink-0 border-r', className)} style={style}>
|
|
50
|
+
<nav aria-label={ariaLabel} className="flex flex-col gap-1 p-3">{items}</nav>
|
|
51
|
+
</aside>
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { defaultNavTranslate, resolveNavLabel, usePanelNav } from '@owlmeans/client-panel'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import {
|
|
5
|
+
NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList
|
|
6
|
+
} from '@/components/ui/navigation-menu'
|
|
7
|
+
|
|
8
|
+
import type { TopNavProps } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Section entries are LINKS, not buttons.
|
|
12
|
+
*
|
|
13
|
+
* The shadcn primitive styles its links as menu tiles — a filled hover/active background, a
|
|
14
|
+
* radius and tile padding — which reads as a row of buttons across the top of an application.
|
|
15
|
+
* These classes land after the primitive's, so `cn`'s tailwind-merge drops the conflicting
|
|
16
|
+
* ones; every neutralised utility here has a counterpart in the primitive, and removing one
|
|
17
|
+
* brings the tile back.
|
|
18
|
+
*/
|
|
19
|
+
const SECTION_LINK = [
|
|
20
|
+
'bg-transparent hover:bg-transparent focus:bg-transparent',
|
|
21
|
+
'rounded-none px-0 py-1',
|
|
22
|
+
'text-sm font-medium text-muted-foreground',
|
|
23
|
+
'hover:text-foreground hover:underline underline-offset-4',
|
|
24
|
+
// Radix marks the active link with a VALUELESS `data-active`, so the state has to be matched
|
|
25
|
+
// on the attribute's presence. `data-[active=true]` — which the shadcn primitive itself uses —
|
|
26
|
+
// matches nothing here, which is why its own active styling never showed either.
|
|
27
|
+
'data-[active]:text-foreground data-[active]:underline data-[active]:bg-transparent',
|
|
28
|
+
'cursor-pointer',
|
|
29
|
+
].join(' ')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The first navigation level — one entry per section.
|
|
33
|
+
*
|
|
34
|
+
* Pressing a section goes to its first screen; the side menu then offers the rest. The
|
|
35
|
+
* viewport is off because no section opens a panel: these are links, not dropdowns.
|
|
36
|
+
*/
|
|
37
|
+
export const TopNav: FC<TopNavProps> = ({ config, translate = defaultNavTranslate, ariaLabel, className, style }) => {
|
|
38
|
+
const model = usePanelNav(config)
|
|
39
|
+
|
|
40
|
+
if (model.sections.length < 1) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return <NavigationMenu viewport={false} aria-label={ariaLabel} className={className} style={style}>
|
|
45
|
+
<NavigationMenuList className="flex-wrap gap-4">
|
|
46
|
+
{model.sections.map(section => {
|
|
47
|
+
const go = model.goSection(section)
|
|
48
|
+
|
|
49
|
+
return <NavigationMenuItem key={section.name}>
|
|
50
|
+
<NavigationMenuLink
|
|
51
|
+
active={model.isSectionActive(section)}
|
|
52
|
+
// A real `href` keeps the entry focusable and openable in a new tab; the click is
|
|
53
|
+
// still handled in-app, so the browser never reloads the whole application.
|
|
54
|
+
href={model.hrefOf(section)}
|
|
55
|
+
onClick={event => { event.preventDefault(); go() }}
|
|
56
|
+
className={cn(SECTION_LINK)}
|
|
57
|
+
>{resolveNavLabel(translate, section.label, `nav.${section.name}`, section.name)}</NavigationMenuLink>
|
|
58
|
+
</NavigationMenuItem>
|
|
59
|
+
})}
|
|
60
|
+
</NavigationMenuList>
|
|
61
|
+
</NavigationMenu>
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PropsWithChildren, ReactNode } from 'react'
|
|
2
|
+
import type { NavTranslate, PanelNavConfig, PanelNavLink } from '@owlmeans/client-panel'
|
|
3
|
+
import type { StyledProps } from '../types.js'
|
|
4
|
+
|
|
5
|
+
interface NavCommonProps extends StyledProps {
|
|
6
|
+
config: PanelNavConfig
|
|
7
|
+
/** See {@link NavTranslate} — omitted, literal labels and humanized aliases are used. */
|
|
8
|
+
translate?: NavTranslate
|
|
9
|
+
ariaLabel?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TopNavProps extends NavCommonProps { }
|
|
13
|
+
|
|
14
|
+
export interface SideNavProps extends NavCommonProps {
|
|
15
|
+
/**
|
|
16
|
+
* `side` is the column beside the content; `bar` is the horizontal strip the narrow
|
|
17
|
+
* viewport gets instead. Both render the same items — only one is visible at a time.
|
|
18
|
+
*/
|
|
19
|
+
variant?: 'side' | 'bar'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NavLayoutProps extends PropsWithChildren<StyledProps> {
|
|
23
|
+
nav: PanelNavConfig
|
|
24
|
+
translate?: NavTranslate
|
|
25
|
+
/** Brand slot — rendered at the far left of the header. */
|
|
26
|
+
title?: ReactNode
|
|
27
|
+
/** Alias the brand navigates to. Defaults to the first section's first item. */
|
|
28
|
+
home?: string
|
|
29
|
+
/** Header right side — sign-in controls, a theme toggle, whatever the app puts there. */
|
|
30
|
+
actions?: ReactNode
|
|
31
|
+
/** Links array renders the standard footer; a node replaces it entirely. */
|
|
32
|
+
footer?: PanelNavLink[] | ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* Styles the HEADER — the sticky bar carrying the brand, the section menu and `actions`.
|
|
35
|
+
*
|
|
36
|
+
* The header is its own SURFACE: it paints an opaque background because content scrolls
|
|
37
|
+
* under it. Give it a background here and you must give it the paired foreground too
|
|
38
|
+
* (`bg-secondary text-secondary-foreground`), exactly as on any other surface — this is the
|
|
39
|
+
* supported way to give an application a dark top bar, and it is why colouring the root
|
|
40
|
+
* instead is not.
|
|
41
|
+
*/
|
|
42
|
+
headerClassName?: string
|
|
43
|
+
/** Styles the content area. NOT its width — see `containerClassName`. */
|
|
44
|
+
contentClassName?: string
|
|
45
|
+
/**
|
|
46
|
+
* The page's horizontal rhythm — width and side padding — applied identically to the header
|
|
47
|
+
* row, the content and the footer row. Give the content a width of its own and it sits inset
|
|
48
|
+
* from a full-width header, which reads as a bug rather than as a layout.
|
|
49
|
+
*
|
|
50
|
+
* MERGED over the shell's default (`mx-auto w-full max-w-6xl px-4`), not substituted for it:
|
|
51
|
+
* pass `max-w-[1280px]` and only the width changes, while the centring and the side padding
|
|
52
|
+
* stay. Name the utility you actually want to move — `px-8` widens the gutters — because
|
|
53
|
+
* anything you do not name keeps its default.
|
|
54
|
+
*/
|
|
55
|
+
containerClassName?: string
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CSSProperties, FC } from 'react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { Toaster as Sonner } from 'sonner'
|
|
4
|
+
import type { ToasterProps } from 'sonner'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
const DARK_CLASS = 'dark'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Follow whatever puts `.dark` on the document element.
|
|
11
|
+
*
|
|
12
|
+
* The panel's Tailwind theme switches on that class (`@custom-variant dark (&:is(.dark *))`),
|
|
13
|
+
* and every OwlMeans app drives it from something different — `next-themes` with
|
|
14
|
+
* `attribute="class"`, the owl theme provider, or nothing at all. Reading the class rather than a
|
|
15
|
+
* provider's hook means this component follows all three and depends on none of them: a package
|
|
16
|
+
* that imported `next-themes` would force it on every consumer of `@owlmeans/web-panel`, and a
|
|
17
|
+
* second copy of a theme provider is its own class of bug.
|
|
18
|
+
*
|
|
19
|
+
* `'system'` is the answer before the first paint and whenever nothing sets the class — sonner
|
|
20
|
+
* resolves it from `prefers-color-scheme`, which is what an app with no toggle means by it.
|
|
21
|
+
*/
|
|
22
|
+
const useDocumentTheme = (): ToasterProps['theme'] => {
|
|
23
|
+
const [theme, setTheme] = useState<ToasterProps['theme']>('system')
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const root = document.documentElement
|
|
27
|
+
const read = () => setTheme(root.classList.contains(DARK_CLASS) ? 'dark' : 'system')
|
|
28
|
+
read()
|
|
29
|
+
const observer = new MutationObserver(read)
|
|
30
|
+
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
|
31
|
+
|
|
32
|
+
return () => observer.disconnect()
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
return theme
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The application's toast surface — mount it ONCE, in the layout.
|
|
40
|
+
*
|
|
41
|
+
* `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
|
|
42
|
+
* message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
|
|
43
|
+
* layout is therefore the only place this belongs.
|
|
44
|
+
*
|
|
45
|
+
* Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
|
|
46
|
+
* below are what its stylesheet paints with, so a toast follows `--popover` the way every other
|
|
47
|
+
* floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
|
|
48
|
+
* shift or recolour the stack keeps the rest.
|
|
49
|
+
*/
|
|
50
|
+
export const Toaster: FC<ToasterProps> = ({ style, theme, className, ...props }) => {
|
|
51
|
+
const detected = useDocumentTheme()
|
|
52
|
+
|
|
53
|
+
return <Sonner
|
|
54
|
+
theme={theme ?? detected}
|
|
55
|
+
className={cn('toaster group', className)}
|
|
56
|
+
richColors
|
|
57
|
+
closeButton
|
|
58
|
+
duration={5000}
|
|
59
|
+
position="top-right"
|
|
60
|
+
style={{
|
|
61
|
+
'--normal-bg': 'var(--popover)',
|
|
62
|
+
'--normal-text': 'var(--popover-foreground)',
|
|
63
|
+
'--normal-border': 'var(--border)',
|
|
64
|
+
...(style ?? {})
|
|
65
|
+
} as CSSProperties}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import type { FC } from 'react'
|
|
3
|
+
import { CookieConsent, CookiePolicy, defaultConsentTranslate } from '@owlmeans/web-consent'
|
|
4
|
+
import type { CookieConsentProps, CookiePolicyProps, ConsentLocale } from '@owlmeans/web-consent'
|
|
5
|
+
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
6
|
+
import { SUPPORTED_LNGS } from '@owlmeans/i18n'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve consent copy through the application first, and the packaged bundle second.
|
|
10
|
+
*
|
|
11
|
+
* The chain matters. `@owlmeans/web-consent` takes `translate` as a prop and, once given one,
|
|
12
|
+
* stops consulting its own translations — so a wrapper that forwarded the framework resolver
|
|
13
|
+
* alone would render the ENGLISH default for every key the application had not overridden, in
|
|
14
|
+
* every language. Falling through to the packaged bundle for the current locale means an
|
|
15
|
+
* application overrides what it wants to and inherits seven languages for the rest.
|
|
16
|
+
*/
|
|
17
|
+
const useConsentTranslate = (
|
|
18
|
+
locale: string, override?: (key: string, defaultValue: string) => string
|
|
19
|
+
): ((key: string, defaultValue: string) => string) => {
|
|
20
|
+
const t = useI18nLib('consent')
|
|
21
|
+
|
|
22
|
+
return useCallback((key: string, defaultValue: string) => {
|
|
23
|
+
if (override != null) {
|
|
24
|
+
return override(key, defaultValue)
|
|
25
|
+
}
|
|
26
|
+
const packaged = defaultConsentTranslate(locale)(key, defaultValue)
|
|
27
|
+
// `t` answers with whatever it is given when the key is unknown, so the packaged string is
|
|
28
|
+
// what it is given — an application's override wins, and everything else stays translated.
|
|
29
|
+
return t(key, { defaultValue: packaged })
|
|
30
|
+
}, [t, locale, override])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The consent dialog, bound to this application's language and translations.
|
|
35
|
+
*
|
|
36
|
+
* `@owlmeans/web-consent` deliberately knows nothing about OwlMeans i18n — one of its consumers is
|
|
37
|
+
* an Astro site with none — so this is where the two meet.
|
|
38
|
+
*/
|
|
39
|
+
export const PanelCookieConsent: FC<CookieConsentProps> = props => {
|
|
40
|
+
const [lng] = useLanguage()
|
|
41
|
+
const locale = props.locale ?? lng
|
|
42
|
+
const translate = useConsentTranslate(locale, props.translate)
|
|
43
|
+
|
|
44
|
+
return <CookieConsent {...props} locale={locale} translate={translate} />
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const PanelCookiePolicy: FC<CookiePolicyProps> = props => {
|
|
48
|
+
const [lng] = useLanguage()
|
|
49
|
+
const locale = props.locale ?? lng
|
|
50
|
+
const translate = useConsentTranslate(locale, props.translate)
|
|
51
|
+
|
|
52
|
+
return <CookiePolicy {...props} locale={locale} translate={translate} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The consent package carries its own locale list because it must build with no dependency on the
|
|
57
|
+
* i18n package at all. This assertion is what keeps the two from drifting: a language added to the
|
|
58
|
+
* framework and not to the bundle fails here, at build time, rather than as a dialog rendering
|
|
59
|
+
* English to the one reader who cannot report it.
|
|
60
|
+
*/
|
|
61
|
+
const _localeParity: readonly ConsentLocale[] = SUPPORTED_LNGS as readonly ConsentLocale[]
|
|
62
|
+
void _localeParity
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './component.js'
|
|
2
|
+
export {
|
|
3
|
+
useConsent, useConsentCategory, consentStore, openConsent, isConsented,
|
|
4
|
+
readConsent, writeConsent, clearConsent, DEFAULT_CONSENT_CATEGORIES,
|
|
5
|
+
CONSENT_KEY, CONSENT_ESSENTIAL, CONSENT_ANALYTICS, CONSENT_MARKETING,
|
|
6
|
+
consentBootstrapScript,
|
|
7
|
+
} from '@owlmeans/web-consent'
|
|
8
|
+
export type {
|
|
9
|
+
ConsentCategory, ConsentOptions, ConsentRecord, ConsentSignal, ConsentState,
|
|
10
|
+
CookieConsentProps, CookiePolicyProps,
|
|
11
|
+
} from '@owlmeans/web-consent'
|
package/src/context.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFlowService } from '@owlmeans/web-flow'
|
|
|
2
2
|
import type { AppConfig , AppContext } from './types.js'
|
|
3
3
|
import { makeContext as makeClientContext, useContext as useCtx } from '@owlmeans/web-client'
|
|
4
4
|
import { apiConfigMiddleware } from '@owlmeans/api-config-client'
|
|
5
|
+
import { appendLoginScreen } from './components/login/append.js'
|
|
5
6
|
|
|
6
7
|
export const makeContext = <C extends AppConfig, T extends AppContext<C>>(cfg: C): T => {
|
|
7
8
|
const context = makeClientContext(cfg) as T
|
|
@@ -11,6 +12,10 @@ export const makeContext = <C extends AppConfig, T extends AppContext<C>>(cfg: C
|
|
|
11
12
|
appendFlowService<C, T>(context)
|
|
12
13
|
context.flow = () => context.service('flow')
|
|
13
14
|
|
|
15
|
+
// Every app on this package gets the shadcn sign-in screen with no wiring of its own; one that
|
|
16
|
+
// wants its logo on it calls `appendLoginScreen` again, which is idempotent.
|
|
17
|
+
appendLoginScreen<C, T>(context)
|
|
18
|
+
|
|
14
19
|
return context
|
|
15
20
|
}
|
|
16
21
|
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createServer } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dirname, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
|
|
9
|
+
let url: string | null = null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Boot a Vite dev server over `tests/harness/` — a real HTTP origin, because the OwlMeans
|
|
13
|
+
* router drives the History API and a `data:` URL has no history to drive.
|
|
14
|
+
*
|
|
15
|
+
* `@` resolves to the package's own dev-only primitive copy, exactly as a consuming app's
|
|
16
|
+
* bundler resolves it to theirs. React is deduped so hooks cross the workspace links.
|
|
17
|
+
*/
|
|
18
|
+
export const getHarnessUrl = async (): Promise<string> => {
|
|
19
|
+
if (url != null) return url
|
|
20
|
+
const server = await createServer({
|
|
21
|
+
configFile: false,
|
|
22
|
+
root: resolve(here, './harness'),
|
|
23
|
+
plugins: [react(), tailwindcss()],
|
|
24
|
+
resolve: {
|
|
25
|
+
alias: { '@': resolve(here, '../src/@') },
|
|
26
|
+
dedupe: ['react', 'react-dom'],
|
|
27
|
+
},
|
|
28
|
+
// Pre-bundled at server start rather than discovered while the first request is being
|
|
29
|
+
// served: a dependency found mid-transform re-optimizes and reloads the page underneath
|
|
30
|
+
// the navigation, which the first `goto` of a cold run pays for as a timeout.
|
|
31
|
+
optimizeDeps: { include: ['sonner'] },
|
|
32
|
+
server: { port: 0 },
|
|
33
|
+
logLevel: 'warn'
|
|
34
|
+
})
|
|
35
|
+
await server.listen()
|
|
36
|
+
const local = server.resolvedUrls?.local?.[0]
|
|
37
|
+
if (local == null) throw new Error('vite did not expose a local URL')
|
|
38
|
+
url = local
|
|
39
|
+
|
|
40
|
+
return url
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const HARNESS_URL = await getHarnessUrl()
|