@djangocfg/layouts 2.1.595 → 2.1.596
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 +15 -13
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +3 -34
- package/src/layouts/SettingsLayout/README.md +1 -0
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +3 -1
- package/src/layouts/SettingsLayout/hooks/{useAccountMenuItems.ts → useAccountMenuItems.tsx} +34 -4
- package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +10 -24
- package/src/layouts/SettingsLayout/index.ts +3 -0
- package/src/layouts/SettingsLayout/intent/parseSettingsHash.test.ts +40 -0
- package/src/layouts/SettingsLayout/intent/parseSettingsHash.ts +44 -0
- package/src/layouts/SettingsLayout/intent/useSettingsIntent.ts +23 -0
- package/src/layouts/SettingsLayout/store.ts +11 -1
- package/src/layouts/SettingsLayout/types.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.596",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -87,14 +87,15 @@
|
|
|
87
87
|
],
|
|
88
88
|
"scripts": {
|
|
89
89
|
"lint": "eslint .",
|
|
90
|
-
"check": "tsc --noEmit"
|
|
90
|
+
"check": "tsc --noEmit",
|
|
91
|
+
"test": "vitest run"
|
|
91
92
|
},
|
|
92
93
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/analytics": "^2.1.
|
|
94
|
-
"@djangocfg/api": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/analytics": "^2.1.596",
|
|
95
|
+
"@djangocfg/api": "^2.1.596",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.596",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.596",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.596",
|
|
98
99
|
"@hookform/resolvers": "^5.2.2",
|
|
99
100
|
"consola": "^3.4.2",
|
|
100
101
|
"lucide-react": "^0.545.0",
|
|
@@ -124,12 +125,12 @@
|
|
|
124
125
|
"uuid": "^11.1.1"
|
|
125
126
|
},
|
|
126
127
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/devtools": "^2.1.
|
|
130
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
132
|
-
"@djangocfg/ui-core": "^2.1.
|
|
128
|
+
"@djangocfg/analytics": "^2.1.596",
|
|
129
|
+
"@djangocfg/api": "^2.1.596",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.596",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.596",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.596",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.596",
|
|
133
134
|
"@storybook/react-vite": "^10.5.0",
|
|
134
135
|
"@types/node": "^25.9.5",
|
|
135
136
|
"@types/react": "19.2.15",
|
|
@@ -138,6 +139,7 @@
|
|
|
138
139
|
"next-intl": "^4.13.2",
|
|
139
140
|
"storybook": "^10.5.0",
|
|
140
141
|
"typescript": "^5.9.3",
|
|
142
|
+
"vitest": "^4.1.10",
|
|
141
143
|
"zustand": "^5.0.14"
|
|
142
144
|
},
|
|
143
145
|
"publishConfig": {
|
|
@@ -11,27 +11,23 @@
|
|
|
11
11
|
|
|
12
12
|
'use client';
|
|
13
13
|
|
|
14
|
-
import { ChevronDown
|
|
14
|
+
import { ChevronDown } from 'lucide-react';
|
|
15
15
|
import { Link } from '@djangocfg/ui-core/components';
|
|
16
16
|
import React, { memo } from 'react';
|
|
17
17
|
|
|
18
18
|
import { useAuth } from '@djangocfg/api/auth';
|
|
19
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
20
19
|
import {
|
|
21
20
|
Avatar,
|
|
22
21
|
AvatarFallback,
|
|
23
22
|
AvatarImage,
|
|
24
23
|
Button,
|
|
25
|
-
LanguageFlag,
|
|
26
24
|
MenuBuilder,
|
|
27
25
|
} from '@djangocfg/ui-core/components';
|
|
28
26
|
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
29
27
|
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
30
28
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
31
29
|
|
|
32
|
-
import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
|
|
33
30
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
34
|
-
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
35
31
|
import { usePeekPin } from '../hooks';
|
|
36
32
|
import { usePrivateLayoutContext } from '../context';
|
|
37
33
|
import { useAccountMenuItems } from '../../SettingsLayout/hooks';
|
|
@@ -52,8 +48,6 @@ interface AccountView {
|
|
|
52
48
|
|
|
53
49
|
function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
54
50
|
const { user } = useAuth();
|
|
55
|
-
const t = useAppT();
|
|
56
|
-
const layoutI18n = useLayoutI18nOptional();
|
|
57
51
|
const { setOpen: setSidebarOpen } = useSidebar();
|
|
58
52
|
const { isMobile } = usePrivateLayoutContext();
|
|
59
53
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
@@ -95,34 +89,9 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
95
89
|
|
|
96
90
|
const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
|
|
97
91
|
|
|
98
|
-
//
|
|
92
|
+
// The app's account links sit between the shared Settings shortcuts and Sign out.
|
|
99
93
|
const extraItems = React.useMemo<MenuItem[]>(() => {
|
|
100
94
|
const items: MenuItem[] = [];
|
|
101
|
-
if (layoutI18n && layoutI18n.locales.length > 1) {
|
|
102
|
-
items.push({
|
|
103
|
-
kind: 'submenu',
|
|
104
|
-
id: 'language',
|
|
105
|
-
label: t('layouts.profile.language') || 'Language',
|
|
106
|
-
icon: Languages,
|
|
107
|
-
items: [
|
|
108
|
-
{
|
|
109
|
-
kind: 'radio-group',
|
|
110
|
-
id: 'locale',
|
|
111
|
-
value: layoutI18n.locale,
|
|
112
|
-
onValueChange: layoutI18n.onLocaleChange,
|
|
113
|
-
options: layoutI18n.locales.map((code) => {
|
|
114
|
-
const meta = getLocaleMeta(code);
|
|
115
|
-
return {
|
|
116
|
-
id: code,
|
|
117
|
-
value: code,
|
|
118
|
-
label: meta.native,
|
|
119
|
-
icon: <LanguageFlag code={code} rounded className="h-3 w-4 shrink-0" />,
|
|
120
|
-
};
|
|
121
|
-
}),
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
95
|
if (accountLinks.length > 0) {
|
|
127
96
|
items.push({ kind: 'separator', id: 'sep-links' });
|
|
128
97
|
for (const link of accountLinks) {
|
|
@@ -130,7 +99,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
130
99
|
}
|
|
131
100
|
}
|
|
132
101
|
return items;
|
|
133
|
-
}, [
|
|
102
|
+
}, [accountLinks]);
|
|
134
103
|
|
|
135
104
|
// Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
|
|
136
105
|
const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
|
|
@@ -79,6 +79,7 @@ the URL hash:
|
|
|
79
79
|
| -------------------- | --------------------------------------- |
|
|
80
80
|
| `#settings` | open, first / last-active section |
|
|
81
81
|
| `#settings/usage` | open, **usage** section active |
|
|
82
|
+
| `#settings/mcp?request=…` | open on **mcp** with a one-shot intent: a section reads it with `useSettingsIntent('mcp')`; the query is stripped from the address after it is read, and a closed dialog drops it |
|
|
82
83
|
| no / other hash | closed |
|
|
83
84
|
|
|
84
85
|
- **Deep-link / refresh** reopens on the same section.
|
|
@@ -5,6 +5,7 @@ import React from 'react';
|
|
|
5
5
|
import { ScrollArea } from '@djangocfg/ui-core/components';
|
|
6
6
|
|
|
7
7
|
import { useSettingsContext } from '../context';
|
|
8
|
+
import { useSettingsIntent } from '../intent/useSettingsIntent';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Right side: the active section's detail in an independently scrolling body.
|
|
@@ -16,6 +17,7 @@ import { useSettingsContext } from '../context';
|
|
|
16
17
|
*/
|
|
17
18
|
export const SettingsPanel: React.FC = () => {
|
|
18
19
|
const { active } = useSettingsContext();
|
|
20
|
+
const intent = useSettingsIntent(active?.id ?? '');
|
|
19
21
|
|
|
20
22
|
if (!active) {
|
|
21
23
|
return (
|
|
@@ -35,7 +37,7 @@ export const SettingsPanel: React.FC = () => {
|
|
|
35
37
|
{/* key forces a fresh subtree per section: resets scroll + transient
|
|
36
38
|
form state when switching, which is the expected settings UX. */}
|
|
37
39
|
<div key={active.id} className="px-6 py-5 md:px-8 md:py-6">
|
|
38
|
-
{active.content}
|
|
40
|
+
{typeof active.content === 'function' ? active.content({ intent }) : active.content}
|
|
39
41
|
</div>
|
|
40
42
|
</ScrollArea>
|
|
41
43
|
</div>
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { LogOut, Settings as SettingsIcon } from 'lucide-react';
|
|
3
|
+
import { Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAuth } from '@djangocfg/api/auth';
|
|
7
7
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
+
import { LanguageFlag } from '@djangocfg/ui-core/components';
|
|
8
9
|
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
9
10
|
|
|
10
11
|
import { useLogout } from '../../../hooks';
|
|
12
|
+
import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
|
|
13
|
+
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
11
14
|
import { useSettingsDialogStore } from '../store';
|
|
12
15
|
|
|
13
16
|
const SETTINGS_SHORTCUT = '⇧⌘,';
|
|
@@ -17,21 +20,25 @@ export interface UseAccountMenuItemsOptions {
|
|
|
17
20
|
extra?: MenuItem[];
|
|
18
21
|
/** Label above the items; defaults to the signed-in user's email. */
|
|
19
22
|
label?: string | null;
|
|
23
|
+
/** Language submenu, shown when the layout i18n context offers more than one locale. @default true */
|
|
24
|
+
language?: boolean;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* The user menu every host shares: email, one row per Settings section (built-in and the host's, as in the dialog's sidebar),
|
|
24
|
-
* then Sign out. Feed it to `MenuBuilder` with any trigger, so a site's own chrome gets the same menu as
|
|
29
|
+
* a Language submenu when the layout offers more than one locale, then Sign out. Feed it to `MenuBuilder` with any trigger, so a site's own chrome gets the same menu as
|
|
25
30
|
* `PrivateLayout`'s sidebar footer instead of a hand-rolled copy.
|
|
26
31
|
*/
|
|
27
|
-
export function useAccountMenuItems({ extra, label }: UseAccountMenuItemsOptions = {}): MenuItem[] {
|
|
32
|
+
export function useAccountMenuItems({ extra, label, language = true }: UseAccountMenuItemsOptions = {}): MenuItem[] {
|
|
28
33
|
const { user } = useAuth();
|
|
29
34
|
const t = useAppT();
|
|
30
35
|
const handleLogout = useLogout();
|
|
36
|
+
const layoutI18n = useLayoutI18nOptional();
|
|
31
37
|
const sections = useSettingsDialogStore((s) => s.sections);
|
|
32
38
|
const headerLabel = label === undefined ? (user?.email ?? null) : label;
|
|
33
39
|
const signOutLabel = t('layouts.profile.signOut');
|
|
34
40
|
const settingsLabel = t('layouts.profile.settings') || 'Settings';
|
|
41
|
+
const languageLabel = t('layouts.profile.language') || 'Language';
|
|
35
42
|
|
|
36
43
|
return useMemo<MenuItem[]>(() => {
|
|
37
44
|
const items: MenuItem[] = [];
|
|
@@ -60,6 +67,29 @@ export function useAccountMenuItems({ extra, label }: UseAccountMenuItemsOptions
|
|
|
60
67
|
});
|
|
61
68
|
});
|
|
62
69
|
|
|
70
|
+
if (language && layoutI18n && layoutI18n.locales.length > 1) {
|
|
71
|
+
items.push({
|
|
72
|
+
kind: 'submenu',
|
|
73
|
+
id: 'language',
|
|
74
|
+
label: languageLabel,
|
|
75
|
+
icon: Languages,
|
|
76
|
+
items: [
|
|
77
|
+
{
|
|
78
|
+
kind: 'radio-group',
|
|
79
|
+
id: 'locale',
|
|
80
|
+
value: layoutI18n.locale,
|
|
81
|
+
onValueChange: layoutI18n.onLocaleChange,
|
|
82
|
+
options: layoutI18n.locales.map((code) => ({
|
|
83
|
+
id: code,
|
|
84
|
+
value: code,
|
|
85
|
+
label: getLocaleMeta(code).native,
|
|
86
|
+
icon: <LanguageFlag code={code} rounded className="h-3 w-4 shrink-0" />,
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
if (extra?.length) items.push(...extra);
|
|
64
94
|
|
|
65
95
|
items.push({ kind: 'separator', id: 'sep-logout' });
|
|
@@ -72,5 +102,5 @@ export function useAccountMenuItems({ extra, label }: UseAccountMenuItemsOptions
|
|
|
72
102
|
onSelect: () => handleLogout(),
|
|
73
103
|
});
|
|
74
104
|
return items;
|
|
75
|
-
}, [headerLabel, settingsLabel, sections, extra, signOutLabel, handleLogout]);
|
|
105
|
+
}, [headerLabel, settingsLabel, languageLabel, language, layoutI18n, sections, extra, signOutLabel, handleLogout]);
|
|
76
106
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* configurable (`urlKey`). Examples:
|
|
8
8
|
* #settings → open, first/last-active section
|
|
9
9
|
* #settings/usage → open, "usage" section active
|
|
10
|
+
* #settings/mcp?request=… → open on "mcp" with a one-shot intent (see `useSettingsIntent`); the query is stripped
|
|
10
11
|
* (no/other hash) → closed
|
|
11
12
|
*
|
|
12
13
|
* Direction of truth:
|
|
@@ -26,6 +27,7 @@ import { useEffect, useRef } from 'react';
|
|
|
26
27
|
|
|
27
28
|
import { useHashState } from '@djangocfg/ui-core/hooks';
|
|
28
29
|
|
|
30
|
+
import { buildSettingsHash as buildHash, parseSettingsHash as parseHash } from '../intent/parseSettingsHash';
|
|
29
31
|
import { useSettingsDialogStore } from '../store';
|
|
30
32
|
|
|
31
33
|
export interface UseSettingsUrlOptions {
|
|
@@ -41,30 +43,6 @@ export interface UseSettingsUrlOptions {
|
|
|
41
43
|
resolveSection?: (id: string | null) => string | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
interface ParsedHash {
|
|
45
|
-
open: boolean;
|
|
46
|
-
section: string | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse a bare fragment (no leading '#', as `useHashState` yields):
|
|
51
|
-
* `"settings/usage"` → { open: true, section: 'usage' }.
|
|
52
|
-
*/
|
|
53
|
-
function parseHash(fragment: string, urlKey: string): ParsedHash {
|
|
54
|
-
if (!fragment) return { open: false, section: null };
|
|
55
|
-
|
|
56
|
-
const [key, ...rest] = fragment.split('/');
|
|
57
|
-
if (key !== urlKey) return { open: false, section: null };
|
|
58
|
-
|
|
59
|
-
const section = rest.join('/') || null;
|
|
60
|
-
return { open: true, section };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Build the bare fragment `"settings/usage"` (or `"settings"` when no section). */
|
|
64
|
-
function buildHash(urlKey: string, section: string | null): string {
|
|
65
|
-
return section ? `${urlKey}/${section}` : urlKey;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
46
|
export function useSettingsUrl(options: UseSettingsUrlOptions = {}): void {
|
|
69
47
|
const { urlKey = 'settings', enabled = true, resolveSection } = options;
|
|
70
48
|
|
|
@@ -75,6 +53,7 @@ export function useSettingsUrl(options: UseSettingsUrlOptions = {}): void {
|
|
|
75
53
|
const activeSection = useSettingsDialogStore((s) => s.activeSection);
|
|
76
54
|
const open = useSettingsDialogStore((s) => s.open);
|
|
77
55
|
const close = useSettingsDialogStore((s) => s.close);
|
|
56
|
+
const setIntent = useSettingsDialogStore((s) => s.setIntent);
|
|
78
57
|
|
|
79
58
|
// Guard against the URL→store and store→URL effects fighting each other:
|
|
80
59
|
// when we write the fragment ourselves we tag the value we wrote and skip the
|
|
@@ -100,6 +79,13 @@ export function useSettingsUrl(options: UseSettingsUrlOptions = {}): void {
|
|
|
100
79
|
// open() preserves the current section when called without one, so only
|
|
101
80
|
// pass a section when the URL actually named a (valid) one.
|
|
102
81
|
open(resolved ?? undefined);
|
|
82
|
+
if (Object.keys(parsed.params).length > 0) {
|
|
83
|
+
// A one-shot intent: hand it to the section, then strip the query so it is not shared, bookmarked or replayed.
|
|
84
|
+
setIntent({ section: resolved, params: parsed.params });
|
|
85
|
+
const stripped = buildHash(urlKey, resolved);
|
|
86
|
+
selfWrittenFragment.current = stripped;
|
|
87
|
+
setFragment(stripped, { replace: true });
|
|
88
|
+
}
|
|
103
89
|
} else if (isOpen) {
|
|
104
90
|
// Hash no longer points at us (back button, manual edit) → close.
|
|
105
91
|
close();
|
|
@@ -29,6 +29,8 @@ export type { AccountMenuButtonProps } from './components/AccountMenuButton';
|
|
|
29
29
|
|
|
30
30
|
// Store + public hooks
|
|
31
31
|
export { useSettingsDialogStore } from './store';
|
|
32
|
+
export { useSettingsIntent } from './intent/useSettingsIntent';
|
|
33
|
+
export type { SettingsIntent } from './intent/useSettingsIntent';
|
|
32
34
|
export {
|
|
33
35
|
useSettingsDialog,
|
|
34
36
|
useSettingsUrl,
|
|
@@ -61,6 +63,7 @@ export {
|
|
|
61
63
|
// Types
|
|
62
64
|
export type {
|
|
63
65
|
SettingsSection,
|
|
66
|
+
SettingsSectionContext,
|
|
64
67
|
SettingsGroup,
|
|
65
68
|
SettingsBuiltins,
|
|
66
69
|
SettingsContent,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildSettingsHash, parseSettingsHash } from './parseSettingsHash';
|
|
4
|
+
|
|
5
|
+
describe('parseSettingsHash', () => {
|
|
6
|
+
it('is closed for an empty or foreign fragment', () => {
|
|
7
|
+
expect(parseSettingsHash('', 'settings').open).toBe(false);
|
|
8
|
+
expect(parseSettingsHash('profile/mcp', 'settings').open).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('opens on the bare key with no section', () => {
|
|
12
|
+
expect(parseSettingsHash('settings', 'settings')).toEqual({ open: true, section: null, params: {} });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('reads the section before the query, not the query as part of it', () => {
|
|
16
|
+
expect(parseSettingsHash('settings/mcp?request=abc&client=Claude', 'settings')).toEqual({
|
|
17
|
+
open: true,
|
|
18
|
+
section: 'mcp',
|
|
19
|
+
params: { request: 'abc', client: 'Claude' },
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('decodes values and keeps a section-less query', () => {
|
|
24
|
+
expect(parseSettingsHash('settings?request=a%2Bb', 'settings').params).toEqual({ request: 'a+b' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('drops an over-long value and caps the number of params', () => {
|
|
28
|
+
const long = 'x'.repeat(5000);
|
|
29
|
+
expect(parseSettingsHash(`settings/mcp?request=${long}&ok=1`, 'settings').params).toEqual({ ok: '1' });
|
|
30
|
+
const many = Array.from({ length: 20 }, (_, i) => `p${i}=1`).join('&');
|
|
31
|
+
expect(Object.keys(parseSettingsHash(`settings/mcp?${many}`, 'settings').params)).toHaveLength(8);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('buildSettingsHash', () => {
|
|
36
|
+
it('never carries params', () => {
|
|
37
|
+
expect(buildSettingsHash('settings', 'mcp')).toBe('settings/mcp');
|
|
38
|
+
expect(buildSettingsHash('settings', null)).toBe('settings');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fragment grammar of the Settings dialog: `settings/<section>?<params>`.
|
|
3
|
+
*
|
|
4
|
+
* The query is a one-shot arrival intent (an assistant's consent request, a pre-filled form). It rides in the
|
|
5
|
+
* fragment so it never reaches a server log, a CDN log or a `Referer`; the dialog reads it once and strips it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Hard caps: the fragment is attacker-reachable input, and the store keeps what it parses. */
|
|
9
|
+
const MAX_PARAMS = 8;
|
|
10
|
+
const MAX_VALUE_LENGTH = 4096;
|
|
11
|
+
|
|
12
|
+
export interface ParsedSettingsHash {
|
|
13
|
+
open: boolean;
|
|
14
|
+
section: string | null;
|
|
15
|
+
/** Flat and length-capped; empty when the fragment carried no query. */
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CLOSED: ParsedSettingsHash = { open: false, section: null, params: {} };
|
|
20
|
+
|
|
21
|
+
/** Parse a bare fragment (no leading '#'): `"settings/mcp?request=abc"` → `{ open, section: 'mcp', params: { request: 'abc' } }`. */
|
|
22
|
+
export function parseSettingsHash(fragment: string, urlKey: string): ParsedSettingsHash {
|
|
23
|
+
if (!fragment) return CLOSED;
|
|
24
|
+
|
|
25
|
+
const queryAt = fragment.indexOf('?');
|
|
26
|
+
const path = queryAt === -1 ? fragment : fragment.slice(0, queryAt);
|
|
27
|
+
const query = queryAt === -1 ? '' : fragment.slice(queryAt + 1);
|
|
28
|
+
|
|
29
|
+
const [key, ...rest] = path.split('/');
|
|
30
|
+
if (key !== urlKey) return CLOSED;
|
|
31
|
+
|
|
32
|
+
const params: Record<string, string> = {};
|
|
33
|
+
for (const [name, value] of new URLSearchParams(query)) {
|
|
34
|
+
if (Object.keys(params).length >= MAX_PARAMS) break;
|
|
35
|
+
if (value.length > MAX_VALUE_LENGTH) continue;
|
|
36
|
+
params[name] = value;
|
|
37
|
+
}
|
|
38
|
+
return { open: true, section: rest.join('/') || null, params };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build the bare fragment `"settings/usage"` (or `"settings"` when no section). Never carries params: an intent is not written back. */
|
|
42
|
+
export function buildSettingsHash(urlKey: string, section: string | null): string {
|
|
43
|
+
return section ? `${urlKey}/${section}` : urlKey;
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useSettingsDialogStore } from '../store';
|
|
6
|
+
|
|
7
|
+
export interface SettingsIntent {
|
|
8
|
+
/** The section the link named, when it named a known one. */
|
|
9
|
+
section: string | null;
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the address asked of this section (`#settings/<section>?…`), or null when it asked nothing. The dialog sets it
|
|
15
|
+
* once from the fragment and strips the query; it stays until the section calls `clear` or the dialog closes, so a
|
|
16
|
+
* section that mounts a moment later still sees it.
|
|
17
|
+
*/
|
|
18
|
+
export function useSettingsIntent(sectionId: string): { params: Record<string, string>; clear: () => void } | null {
|
|
19
|
+
const intent = useSettingsDialogStore((s) => s.intent);
|
|
20
|
+
const clearIntent = useSettingsDialogStore((s) => s.clearIntent);
|
|
21
|
+
const clear = useCallback(() => clearIntent(), [clearIntent]);
|
|
22
|
+
return intent && intent.section === sectionId ? { params: intent.params, clear } : null;
|
|
23
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { create } from 'zustand';
|
|
17
17
|
|
|
18
|
+
import type { SettingsIntent } from './intent/useSettingsIntent';
|
|
19
|
+
|
|
18
20
|
/** What a menu needs to link to a section; the dialog registers these (host sections are only known to it). */
|
|
19
21
|
export interface SettingsMenuSection {
|
|
20
22
|
id: string;
|
|
@@ -41,6 +43,10 @@ interface SettingsDialogState {
|
|
|
41
43
|
toggle: () => void;
|
|
42
44
|
/** Switch the active section (no-op on open state). */
|
|
43
45
|
setSection: (sectionId: string) => void;
|
|
46
|
+
/** One-shot parameters from the address (`#settings/<section>?…`), read by the section they name. */
|
|
47
|
+
intent: SettingsIntent | null;
|
|
48
|
+
setIntent: (intent: SettingsIntent) => void;
|
|
49
|
+
clearIntent: () => void;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
@@ -71,7 +77,11 @@ export const useSettingsDialogStore = create<SettingsDialogState>((set, get) =>
|
|
|
71
77
|
isOpen: true,
|
|
72
78
|
activeSection: sectionId ?? state.activeSection,
|
|
73
79
|
})),
|
|
74
|
-
|
|
80
|
+
/* Closing drops an unanswered intent: reopening later must not replay a stale request. */
|
|
81
|
+
close: () => set({ isOpen: false, intent: null }),
|
|
75
82
|
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
|
76
83
|
setSection: (sectionId) => set({ activeSection: sectionId }),
|
|
84
|
+
intent: null,
|
|
85
|
+
setIntent: (intent) => set({ intent }),
|
|
86
|
+
clearIntent: () => set({ intent: null }),
|
|
77
87
|
}));
|
|
@@ -23,6 +23,12 @@ export interface SettingsGroup {
|
|
|
23
23
|
order?: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** What a function `content` receives. */
|
|
27
|
+
export interface SettingsSectionContext {
|
|
28
|
+
/** The one-shot intent for this section, or null; `clear` consumes it. */
|
|
29
|
+
intent: { params: Record<string, string>; clear: () => void } | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/**
|
|
27
33
|
* One settings section = one nav entry + one detail panel.
|
|
28
34
|
*
|
|
@@ -42,8 +48,11 @@ export interface SettingsSection {
|
|
|
42
48
|
title?: React.ReactNode;
|
|
43
49
|
/** Sub-heading under the panel title. */
|
|
44
50
|
description?: React.ReactNode;
|
|
45
|
-
/**
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The panel body. Rendered only while this section is active (Radix unmounts closed dialogs). A function receives what the
|
|
53
|
+
* address asked of this section (`#settings/<id>?…`, see `useSettingsIntent`), so a package can react to it without importing layouts.
|
|
54
|
+
*/
|
|
55
|
+
content: React.ReactNode | ((context: SettingsSectionContext) => React.ReactNode);
|
|
47
56
|
/** Extra terms the search box matches against, beyond the label. */
|
|
48
57
|
keywords?: string[];
|
|
49
58
|
/** Sort order within the group (ascending). Defaults to declaration order. */
|