@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.69
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/.agents/rules/components.md +1 -1
- package/.agents/rules/shell.md +3 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/layouts/components/app-sidebar.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/locales/system/en-US.ts +7 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +7 -0
- package/client/pages/auth/auth-page.css +15 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/shared.tsx +3 -1
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +4 -7
- package/package.json +24 -23
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +8 -1
- package/tests/components/navigation-menu.test.tsx +24 -14
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +12 -4
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/vite.config.ts +0 -22
- package/client/agent-annotations-host.ts +0 -25
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { useId, useRef, useState, type ReactElement } from 'react';
|
|
2
|
+
import { Button } from '#components/ui/button';
|
|
3
|
+
import { Input } from '#components/ui/input';
|
|
4
|
+
import {
|
|
5
|
+
Select,
|
|
6
|
+
SelectContent,
|
|
7
|
+
SelectGroup,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from '#components/ui/select';
|
|
12
|
+
import {
|
|
13
|
+
Popover,
|
|
14
|
+
PopoverContent,
|
|
15
|
+
PopoverDescription,
|
|
16
|
+
PopoverTitle,
|
|
17
|
+
PopoverTrigger,
|
|
18
|
+
} from '#components/ui/popover';
|
|
19
|
+
|
|
20
|
+
export interface PermissionFilterCondition {
|
|
21
|
+
readonly kind: 'condition';
|
|
22
|
+
readonly path: readonly string[];
|
|
23
|
+
readonly operator: string;
|
|
24
|
+
readonly value?: string | number | boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface PermissionFilterValue {
|
|
27
|
+
readonly kind: 'group';
|
|
28
|
+
readonly logic: 'and' | 'or';
|
|
29
|
+
readonly items: readonly PermissionFilterCondition[];
|
|
30
|
+
}
|
|
31
|
+
export interface PermissionFilterLabels {
|
|
32
|
+
readonly configure: string;
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly description: string;
|
|
35
|
+
readonly all: string;
|
|
36
|
+
readonly any: string;
|
|
37
|
+
readonly field: string;
|
|
38
|
+
readonly operator: string;
|
|
39
|
+
readonly valueType: string;
|
|
40
|
+
readonly value: string;
|
|
41
|
+
readonly text: string;
|
|
42
|
+
readonly number: string;
|
|
43
|
+
readonly boolean: string;
|
|
44
|
+
readonly true: string;
|
|
45
|
+
readonly false: string;
|
|
46
|
+
readonly add: string;
|
|
47
|
+
readonly limit: string;
|
|
48
|
+
readonly remove: string;
|
|
49
|
+
readonly cancel: string;
|
|
50
|
+
readonly apply: string;
|
|
51
|
+
readonly invalid: string;
|
|
52
|
+
readonly advanced: string;
|
|
53
|
+
readonly reconfigure: string;
|
|
54
|
+
readonly operators: Readonly<Record<string, string>>;
|
|
55
|
+
}
|
|
56
|
+
export interface PermissionFilterProps {
|
|
57
|
+
readonly open: boolean;
|
|
58
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
59
|
+
readonly value: unknown;
|
|
60
|
+
readonly fields: readonly { value: string; label: string }[];
|
|
61
|
+
readonly labels: PermissionFilterLabels;
|
|
62
|
+
readonly disabled?: boolean;
|
|
63
|
+
readonly onApply: (value: PermissionFilterValue) => void;
|
|
64
|
+
}
|
|
65
|
+
interface DraftCondition {
|
|
66
|
+
id: number;
|
|
67
|
+
field: string;
|
|
68
|
+
operator: string;
|
|
69
|
+
type: string;
|
|
70
|
+
value: string;
|
|
71
|
+
}
|
|
72
|
+
const operators = [
|
|
73
|
+
'$eq',
|
|
74
|
+
'$ne',
|
|
75
|
+
'$includes',
|
|
76
|
+
'$gt',
|
|
77
|
+
'$gte',
|
|
78
|
+
'$lt',
|
|
79
|
+
'$lte',
|
|
80
|
+
'$empty',
|
|
81
|
+
'$notEmpty',
|
|
82
|
+
];
|
|
83
|
+
function unary(operator: string): boolean {
|
|
84
|
+
return operator === '$empty' || operator === '$notEmpty';
|
|
85
|
+
}
|
|
86
|
+
function numeric(operator: string): boolean {
|
|
87
|
+
return ['$gt', '$gte', '$lt', '$lte'].includes(operator);
|
|
88
|
+
}
|
|
89
|
+
function read(
|
|
90
|
+
value: unknown,
|
|
91
|
+
): { logic: 'and' | 'or'; rows: DraftCondition[] } | undefined {
|
|
92
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
93
|
+
const root = value as { kind?: unknown; logic?: unknown; items?: unknown };
|
|
94
|
+
const items =
|
|
95
|
+
root.kind === 'condition'
|
|
96
|
+
? [value]
|
|
97
|
+
: root.kind === 'group' &&
|
|
98
|
+
(root.logic === 'and' || root.logic === 'or') &&
|
|
99
|
+
Array.isArray(root.items)
|
|
100
|
+
? root.items
|
|
101
|
+
: undefined;
|
|
102
|
+
if (!items?.length || items.length > 20) return undefined;
|
|
103
|
+
const rows: DraftCondition[] = [];
|
|
104
|
+
for (const item of items) {
|
|
105
|
+
if (!item || typeof item !== 'object') return undefined;
|
|
106
|
+
const node = item as {
|
|
107
|
+
kind?: unknown;
|
|
108
|
+
path?: unknown;
|
|
109
|
+
operator?: unknown;
|
|
110
|
+
value?: unknown;
|
|
111
|
+
};
|
|
112
|
+
if (
|
|
113
|
+
node.kind !== 'condition' ||
|
|
114
|
+
!Array.isArray(node.path) ||
|
|
115
|
+
node.path.length !== 1 ||
|
|
116
|
+
typeof node.path[0] !== 'string' ||
|
|
117
|
+
typeof node.operator !== 'string' ||
|
|
118
|
+
!operators.includes(node.operator) ||
|
|
119
|
+
(!unary(node.operator) &&
|
|
120
|
+
!['string', 'number', 'boolean'].includes(typeof node.value))
|
|
121
|
+
)
|
|
122
|
+
return undefined;
|
|
123
|
+
rows.push({
|
|
124
|
+
id: rows.length,
|
|
125
|
+
field: node.path[0],
|
|
126
|
+
operator: node.operator,
|
|
127
|
+
type:
|
|
128
|
+
typeof node.value === 'number'
|
|
129
|
+
? 'number'
|
|
130
|
+
: typeof node.value === 'boolean'
|
|
131
|
+
? 'boolean'
|
|
132
|
+
: 'text',
|
|
133
|
+
value:
|
|
134
|
+
typeof node.value === 'string'
|
|
135
|
+
? node.value
|
|
136
|
+
: typeof node.value === 'number' || typeof node.value === 'boolean'
|
|
137
|
+
? String(node.value)
|
|
138
|
+
: '',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { logic: root.logic === 'or' ? 'or' : 'and', rows };
|
|
142
|
+
}
|
|
143
|
+
function Choice({
|
|
144
|
+
label,
|
|
145
|
+
items,
|
|
146
|
+
value,
|
|
147
|
+
onChange,
|
|
148
|
+
}: {
|
|
149
|
+
label: string;
|
|
150
|
+
items: readonly { value: string; label: string }[];
|
|
151
|
+
value: string;
|
|
152
|
+
onChange: (value: string) => void;
|
|
153
|
+
}): ReactElement {
|
|
154
|
+
return (
|
|
155
|
+
<Select
|
|
156
|
+
items={items}
|
|
157
|
+
value={value}
|
|
158
|
+
onValueChange={(next) => {
|
|
159
|
+
if (next !== null) onChange(next);
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<SelectTrigger size='sm' className='w-full' aria-label={label}>
|
|
163
|
+
<SelectValue />
|
|
164
|
+
</SelectTrigger>
|
|
165
|
+
<SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
|
|
166
|
+
<SelectGroup>
|
|
167
|
+
{items.map((item) => (
|
|
168
|
+
<SelectItem key={item.value} value={item.value}>
|
|
169
|
+
{item.label}
|
|
170
|
+
</SelectItem>
|
|
171
|
+
))}
|
|
172
|
+
</SelectGroup>
|
|
173
|
+
</SelectContent>
|
|
174
|
+
</Select>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
function FilterDraft({
|
|
178
|
+
value,
|
|
179
|
+
fields,
|
|
180
|
+
labels: w,
|
|
181
|
+
onApply,
|
|
182
|
+
onCancel,
|
|
183
|
+
}: Pick<PermissionFilterProps, 'value' | 'fields' | 'labels' | 'onApply'> & {
|
|
184
|
+
onCancel: () => void;
|
|
185
|
+
}): ReactElement {
|
|
186
|
+
const [initial] = useState(() => read(value));
|
|
187
|
+
const empty = (): DraftCondition => ({
|
|
188
|
+
id: 0,
|
|
189
|
+
field: fields[0]?.value ?? '',
|
|
190
|
+
operator: '$eq',
|
|
191
|
+
type: 'text',
|
|
192
|
+
value: '',
|
|
193
|
+
});
|
|
194
|
+
const [advanced, setAdvanced] = useState(value !== undefined && !initial);
|
|
195
|
+
const [logic, setLogic] = useState<'and' | 'or'>(initial?.logic ?? 'and');
|
|
196
|
+
const [rows, setRows] = useState<DraftCondition[]>(
|
|
197
|
+
() => initial?.rows ?? [empty()],
|
|
198
|
+
);
|
|
199
|
+
const [nextId, setNextId] = useState(rows.length);
|
|
200
|
+
const id = useId();
|
|
201
|
+
const addRef = useRef<HTMLButtonElement>(null);
|
|
202
|
+
const update = (rowId: number, patch: Partial<DraftCondition>) =>
|
|
203
|
+
setRows((current) =>
|
|
204
|
+
current.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
|
205
|
+
);
|
|
206
|
+
const valid =
|
|
207
|
+
!advanced &&
|
|
208
|
+
rows.length > 0 &&
|
|
209
|
+
rows.every(
|
|
210
|
+
(row) =>
|
|
211
|
+
fields.some((field) => field.value === row.field) &&
|
|
212
|
+
(unary(row.operator) ||
|
|
213
|
+
(row.value.trim() !== '' &&
|
|
214
|
+
(row.type !== 'number' || Number.isFinite(Number(row.value))) &&
|
|
215
|
+
(row.type !== 'boolean' || ['true', 'false'].includes(row.value)))),
|
|
216
|
+
);
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
className='flex min-h-0 flex-col gap-3'
|
|
220
|
+
onKeyDown={(event) => {
|
|
221
|
+
if (
|
|
222
|
+
event.key === 'Enter' &&
|
|
223
|
+
!event.nativeEvent.isComposing &&
|
|
224
|
+
event.target instanceof HTMLInputElement
|
|
225
|
+
)
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{advanced ? (
|
|
230
|
+
<div className='flex flex-col gap-3'>
|
|
231
|
+
<p className='text-sm text-muted-foreground'>{w.advanced}</p>
|
|
232
|
+
<Button
|
|
233
|
+
type='button'
|
|
234
|
+
variant='outline'
|
|
235
|
+
size='sm'
|
|
236
|
+
onClick={() => {
|
|
237
|
+
setAdvanced(false);
|
|
238
|
+
setRows([empty()]);
|
|
239
|
+
setNextId(1);
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
{w.reconfigure}
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
) : (
|
|
246
|
+
<>
|
|
247
|
+
<Choice
|
|
248
|
+
label={w.title}
|
|
249
|
+
value={logic}
|
|
250
|
+
items={[
|
|
251
|
+
{ value: 'and', label: w.all },
|
|
252
|
+
{ value: 'or', label: w.any },
|
|
253
|
+
]}
|
|
254
|
+
onChange={(value) => setLogic(value === 'or' ? 'or' : 'and')}
|
|
255
|
+
/>
|
|
256
|
+
<div className='flex min-h-0 flex-col gap-3 overflow-y-auto'>
|
|
257
|
+
{rows.map((row, index) => (
|
|
258
|
+
<fieldset
|
|
259
|
+
key={row.id}
|
|
260
|
+
className='flex flex-col gap-2 rounded-lg border p-3'
|
|
261
|
+
>
|
|
262
|
+
<legend className='sr-only'>{`${w.field} ${index + 1}`}</legend>
|
|
263
|
+
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2'>
|
|
264
|
+
<Choice
|
|
265
|
+
label={`${w.field} ${index + 1}`}
|
|
266
|
+
items={fields}
|
|
267
|
+
value={row.field}
|
|
268
|
+
onChange={(field) => update(row.id, { field })}
|
|
269
|
+
/>
|
|
270
|
+
<Choice
|
|
271
|
+
label={`${w.operator} ${index + 1}`}
|
|
272
|
+
items={operators.map((value) => ({
|
|
273
|
+
value,
|
|
274
|
+
label: w.operators[value] ?? value,
|
|
275
|
+
}))}
|
|
276
|
+
value={row.operator}
|
|
277
|
+
onChange={(operator) =>
|
|
278
|
+
update(row.id, {
|
|
279
|
+
operator,
|
|
280
|
+
...(numeric(operator)
|
|
281
|
+
? { type: 'number' }
|
|
282
|
+
: operator === '$includes'
|
|
283
|
+
? { type: 'text' }
|
|
284
|
+
: {}),
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
/>
|
|
288
|
+
{!unary(row.operator) && (
|
|
289
|
+
<>
|
|
290
|
+
{['$eq', '$ne'].includes(row.operator) && (
|
|
291
|
+
<Choice
|
|
292
|
+
label={`${w.valueType} ${index + 1}`}
|
|
293
|
+
items={[
|
|
294
|
+
{ value: 'text', label: w.text },
|
|
295
|
+
{ value: 'number', label: w.number },
|
|
296
|
+
{ value: 'boolean', label: w.boolean },
|
|
297
|
+
]}
|
|
298
|
+
value={row.type}
|
|
299
|
+
onChange={(type) =>
|
|
300
|
+
update(row.id, { type, value: '' })
|
|
301
|
+
}
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
{row.type === 'boolean' ? (
|
|
305
|
+
<Choice
|
|
306
|
+
label={`${w.value} ${index + 1}`}
|
|
307
|
+
items={[
|
|
308
|
+
{ value: 'true', label: w.true },
|
|
309
|
+
{ value: 'false', label: w.false },
|
|
310
|
+
]}
|
|
311
|
+
value={row.value}
|
|
312
|
+
onChange={(value) => update(row.id, { value })}
|
|
313
|
+
/>
|
|
314
|
+
) : (
|
|
315
|
+
<Input
|
|
316
|
+
aria-label={`${w.value} ${index + 1}`}
|
|
317
|
+
aria-describedby={`${id}-validation`}
|
|
318
|
+
placeholder={w.value}
|
|
319
|
+
value={row.value}
|
|
320
|
+
inputMode={row.type === 'number' ? 'decimal' : 'text'}
|
|
321
|
+
onChange={(event) =>
|
|
322
|
+
update(row.id, { value: event.target.value })
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
</>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
<div className='flex justify-end'>
|
|
330
|
+
<Button
|
|
331
|
+
type='button'
|
|
332
|
+
variant='ghost'
|
|
333
|
+
size='sm'
|
|
334
|
+
onClick={() => {
|
|
335
|
+
setRows((current) =>
|
|
336
|
+
current.filter((item) => item.id !== row.id),
|
|
337
|
+
);
|
|
338
|
+
queueMicrotask(() => addRef.current?.focus());
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{w.remove}
|
|
342
|
+
</Button>
|
|
343
|
+
</div>
|
|
344
|
+
</fieldset>
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
<Button
|
|
348
|
+
ref={addRef}
|
|
349
|
+
type='button'
|
|
350
|
+
variant='outline'
|
|
351
|
+
size='sm'
|
|
352
|
+
disabled={rows.length >= 20}
|
|
353
|
+
onClick={() => {
|
|
354
|
+
setRows((current) => [...current, { ...empty(), id: nextId }]);
|
|
355
|
+
setNextId((current) => current + 1);
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{w.add}
|
|
359
|
+
</Button>
|
|
360
|
+
{rows.length >= 20 && (
|
|
361
|
+
<p className='text-xs text-muted-foreground'>{w.limit}</p>
|
|
362
|
+
)}
|
|
363
|
+
{!valid && (
|
|
364
|
+
<p
|
|
365
|
+
id={`${id}-validation`}
|
|
366
|
+
className='text-sm text-muted-foreground'
|
|
367
|
+
>
|
|
368
|
+
{w.invalid}
|
|
369
|
+
</p>
|
|
370
|
+
)}
|
|
371
|
+
</>
|
|
372
|
+
)}
|
|
373
|
+
<div className='flex shrink-0 justify-end gap-2 border-t pt-3'>
|
|
374
|
+
<Button type='button' variant='outline' size='sm' onClick={onCancel}>
|
|
375
|
+
{w.cancel}
|
|
376
|
+
</Button>
|
|
377
|
+
<Button
|
|
378
|
+
type='button'
|
|
379
|
+
size='sm'
|
|
380
|
+
disabled={!valid}
|
|
381
|
+
onClick={() =>
|
|
382
|
+
onApply({
|
|
383
|
+
kind: 'group',
|
|
384
|
+
logic,
|
|
385
|
+
items: rows.map((row) => ({
|
|
386
|
+
kind: 'condition',
|
|
387
|
+
path: [row.field],
|
|
388
|
+
operator: row.operator,
|
|
389
|
+
...(unary(row.operator)
|
|
390
|
+
? {}
|
|
391
|
+
: {
|
|
392
|
+
value:
|
|
393
|
+
row.type === 'number'
|
|
394
|
+
? Number(row.value)
|
|
395
|
+
: row.type === 'boolean'
|
|
396
|
+
? row.value === 'true'
|
|
397
|
+
: row.value,
|
|
398
|
+
}),
|
|
399
|
+
})),
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
>
|
|
403
|
+
{w.apply}
|
|
404
|
+
</Button>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
/** Native filter input; Apply edits the containing role draft, never a server directly. */
|
|
410
|
+
export function PermissionFilter({
|
|
411
|
+
open,
|
|
412
|
+
onOpenChange,
|
|
413
|
+
value,
|
|
414
|
+
fields,
|
|
415
|
+
labels,
|
|
416
|
+
disabled = false,
|
|
417
|
+
onApply,
|
|
418
|
+
}: PermissionFilterProps): ReactElement {
|
|
419
|
+
return (
|
|
420
|
+
<Popover open={open && !disabled} onOpenChange={onOpenChange}>
|
|
421
|
+
<PopoverTrigger
|
|
422
|
+
render={
|
|
423
|
+
<Button
|
|
424
|
+
type='button'
|
|
425
|
+
variant='outline'
|
|
426
|
+
size='sm'
|
|
427
|
+
className='h-6 px-2 text-xs'
|
|
428
|
+
disabled={disabled}
|
|
429
|
+
/>
|
|
430
|
+
}
|
|
431
|
+
>
|
|
432
|
+
{labels.configure}
|
|
433
|
+
</PopoverTrigger>
|
|
434
|
+
<PopoverContent
|
|
435
|
+
align='start'
|
|
436
|
+
className='flex max-h-[min(36rem,var(--available-height))] w-xl max-w-(--available-width) flex-col gap-3 p-4'
|
|
437
|
+
>
|
|
438
|
+
<PopoverTitle>{labels.title}</PopoverTitle>
|
|
439
|
+
<PopoverDescription>{labels.description}</PopoverDescription>
|
|
440
|
+
{open && !disabled && (
|
|
441
|
+
<FilterDraft
|
|
442
|
+
value={value}
|
|
443
|
+
fields={fields}
|
|
444
|
+
labels={labels}
|
|
445
|
+
onCancel={() => onOpenChange(false)}
|
|
446
|
+
onApply={(filter) => {
|
|
447
|
+
onApply(filter);
|
|
448
|
+
onOpenChange(false);
|
|
449
|
+
}}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
</PopoverContent>
|
|
453
|
+
</Popover>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
Dialog,
|
|
21
21
|
DialogClose,
|
|
22
22
|
DialogDescription,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
DialogHeader,
|
|
23
25
|
DialogOverlay,
|
|
24
26
|
DialogPortal,
|
|
25
27
|
DialogTitle,
|
|
@@ -147,7 +149,10 @@ export function RouteOverlay({
|
|
|
147
149
|
return true;
|
|
148
150
|
}}
|
|
149
151
|
className={cn(
|
|
150
|
-
|
|
152
|
+
// The frame of `DialogContent` (position, surface, ring, motion), laid out as a column so the header and
|
|
153
|
+
// footer stay fixed while only the body scrolls within the viewport cap.
|
|
154
|
+
'fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
|
|
155
|
+
// Route dialogs hold the 5-8 field forms of guideline I1, hence wider than the plain dialog's default.
|
|
151
156
|
'flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl',
|
|
152
157
|
// The viewport constraints are deliberate; all ordinary styling uses theme tokens.
|
|
153
158
|
drawer &&
|
|
@@ -155,27 +160,35 @@ export function RouteOverlay({
|
|
|
155
160
|
className,
|
|
156
161
|
)}
|
|
157
162
|
>
|
|
163
|
+
{/* The regions reproduce `DialogContent`'s `p-6 gap-5` around the dialog primitives, split so the body can
|
|
164
|
+
scroll on its own: the header and body own the padding, and the body's top padding is the gap. */}
|
|
158
165
|
<header
|
|
159
166
|
data-slot='route-overlay-header'
|
|
160
|
-
className='
|
|
167
|
+
className='shrink-0 px-6 pt-6'
|
|
161
168
|
>
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
<DialogHeader>
|
|
170
|
+
<DialogTitle>{title}</DialogTitle>
|
|
171
|
+
{description != null && (
|
|
172
|
+
<DialogDescription>{description}</DialogDescription>
|
|
173
|
+
)}
|
|
174
|
+
</DialogHeader>
|
|
166
175
|
</header>
|
|
167
176
|
<div
|
|
168
177
|
data-slot='route-overlay-body'
|
|
169
|
-
className=
|
|
178
|
+
className={cn(
|
|
179
|
+
'min-h-0 flex-1 overflow-y-auto px-6 pt-5',
|
|
180
|
+
footer != null ? 'pb-5' : 'pb-6',
|
|
181
|
+
)}
|
|
170
182
|
>
|
|
171
183
|
{children}
|
|
172
184
|
</div>
|
|
173
185
|
{footer != null && (
|
|
174
|
-
<footer
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
<footer data-slot='route-overlay-footer' className='shrink-0'>
|
|
187
|
+
{/* `DialogFooter` pulls itself out of `DialogContent`'s padding with negative margins; here it
|
|
188
|
+
already sits at the panel's edges, and the panel clips the corners. */}
|
|
189
|
+
<DialogFooter className='mx-0 mb-0 rounded-none'>
|
|
190
|
+
{footer}
|
|
191
|
+
</DialogFooter>
|
|
179
192
|
</footer>
|
|
180
193
|
)}
|
|
181
194
|
<DialogClose
|
|
@@ -183,7 +196,7 @@ export function RouteOverlay({
|
|
|
183
196
|
<Button
|
|
184
197
|
variant='ghost'
|
|
185
198
|
size='icon-sm'
|
|
186
|
-
className='absolute top-
|
|
199
|
+
className='absolute top-2 right-2'
|
|
187
200
|
/>
|
|
188
201
|
}
|
|
189
202
|
>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
|
|
2
|
+
import { cn } from 'cn';
|
|
3
|
+
import { CheckIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
|
6
|
+
return (
|
|
7
|
+
<CheckboxPrimitive.Root
|
|
8
|
+
data-slot='checkbox'
|
|
9
|
+
className={cn(
|
|
10
|
+
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
>
|
|
15
|
+
<CheckboxPrimitive.Indicator
|
|
16
|
+
data-slot='checkbox-indicator'
|
|
17
|
+
className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
|
|
18
|
+
>
|
|
19
|
+
<CheckIcon />
|
|
20
|
+
</CheckboxPrimitive.Indicator>
|
|
21
|
+
</CheckboxPrimitive.Root>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { Checkbox };
|
|
@@ -25,8 +25,6 @@ import {
|
|
|
25
25
|
} from '#components/ui/tooltip';
|
|
26
26
|
import { PanelLeftIcon } from 'lucide-react';
|
|
27
27
|
|
|
28
|
-
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
|
29
|
-
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
30
28
|
const SIDEBAR_WIDTH = '16rem';
|
|
31
29
|
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
|
32
30
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
|
@@ -82,8 +80,7 @@ function SidebarProvider({
|
|
|
82
80
|
_setOpen(openState);
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
//
|
|
86
|
-
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
83
|
+
// Persistence belongs to the application-scoped preference, not an origin-wide cookie.
|
|
87
84
|
},
|
|
88
85
|
[setOpenProp, open],
|
|
89
86
|
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Switch as SwitchPrimitive } from '@base-ui/react/switch';
|
|
2
|
+
import { cn } from 'cn';
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
|
|
5
|
+
function Switch({
|
|
6
|
+
className,
|
|
7
|
+
size = 'default',
|
|
8
|
+
...props
|
|
9
|
+
}: SwitchPrimitive.Root.Props & {
|
|
10
|
+
size?: 'sm' | 'default';
|
|
11
|
+
}): ReactElement {
|
|
12
|
+
return (
|
|
13
|
+
<SwitchPrimitive.Root
|
|
14
|
+
data-slot='switch'
|
|
15
|
+
data-size={size}
|
|
16
|
+
className={cn(
|
|
17
|
+
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<SwitchPrimitive.Thumb
|
|
23
|
+
data-slot='switch-thumb'
|
|
24
|
+
className='pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground'
|
|
25
|
+
/>
|
|
26
|
+
</SwitchPrimitive.Root>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { Switch };
|