@viliha/vui-ui 1.6.3 → 1.8.0
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/AGENT.md +4 -0
- package/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/record-view.tsx +171 -13
- package/template/.env.example +9 -4
- package/template/app/(app)/system/regions/regions-table.tsx +65 -8
- package/template/app/_components/brand.tsx +30 -0
package/AGENT.md
CHANGED
|
@@ -334,6 +334,10 @@ Build forms with VUI and shadcn/ui together, reaching for shadcn Form, React Hoo
|
|
|
334
334
|
|
|
335
335
|
Never hand-build an HTML table; always use `RecordView`. Configure columns through its field props (`editable`, `required`, `copyable`, `options`, `render`), and let `RecordView` own the rest: sorting, filtering, pagination, bulk actions, and import/export.
|
|
336
336
|
|
|
337
|
+
## Filtering
|
|
338
|
+
|
|
339
|
+
The Filter panel is a single keyword box by default (built in, matches all fields). For a labeled control per field, set `filterable` on the relevant fields and never hand-roll a filter form: `filterable: true` (text) or `filterable: { control, label, placeholder, options }` where `control` is `"text" | "number" | "date" | "select" | "combobox" | "checkbox"`. When any field is filterable the panel shows a control per field plus Search / Clear. It only **collects** values — handle `onFilter(values)` on `RecordView` to run a query or client filter (Search and Clear both call it). Types: `FilterControl`, `FieldFilter`, `FilterValues<T>`. Need a control kind that isn't listed? Extend the `FilterControl` union in the component.
|
|
340
|
+
|
|
337
341
|
## Add / edit form
|
|
338
342
|
|
|
339
343
|
The buffered add/edit form renders in one of two layouts:
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ backward-compatible features, **major** for breaking changes.
|
|
|
7
7
|
|
|
8
8
|
To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
|
|
9
9
|
|
|
10
|
+
## 1.8.0 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Per-field filtering in `RecordView`** (opt-in, backward compatible). Mark a
|
|
15
|
+
field `filterable` and the Filter panel switches from the single keyword box to
|
|
16
|
+
a labeled control per field plus **Search / Clear**. The control is dynamic so
|
|
17
|
+
the front end can compose a different filter form per request:
|
|
18
|
+
- `filterable: true` → a text input.
|
|
19
|
+
- `filterable: { control, label, placeholder, options }` → pick the control:
|
|
20
|
+
`"text" | "number" | "date" | "select" | "combobox" | "checkbox"` (unknown or
|
|
21
|
+
omitted → text). `options` falls back to the field's own `options`.
|
|
22
|
+
- New exported types: `FilterControl`, `FieldFilter`, `FilterValues<T>`.
|
|
23
|
+
- The panel **gathers values only** — it does not match rows in per-field mode.
|
|
24
|
+
Wire matching through the new `RecordView` prop `onFilter(values)` (Search and
|
|
25
|
+
Clear both call it), typically a server query or your own client filter. The
|
|
26
|
+
single-keyword box (and its built-in row matching) is unchanged when no field
|
|
27
|
+
is `filterable`.
|
|
28
|
+
|
|
29
|
+
**For agents:** to add per-field filters, only set `filterable` on the relevant
|
|
30
|
+
`RecordField`s and handle `onFilter` — never hand-roll a filter form. Extend
|
|
31
|
+
`FilterControl` when a new control kind is needed. Demo: `system/regions`
|
|
32
|
+
(Name + Code). Docs: `/docs/data-table`.
|
|
33
|
+
|
|
34
|
+
## 1.7.0 — 2026-07-25
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Configurable favicon** via the runtime brand system. `BrandProvider` gains a
|
|
39
|
+
`faviconUrl` field (alongside `logoUrl`): set `NEXT_PUBLIC_FAVICON_URL` for a
|
|
40
|
+
build-time default, return `faviconUrl` from your `NEXT_PUBLIC_BRAND_URL` JSON
|
|
41
|
+
(or call `useBrand().setBrand({ faviconUrl })`) to change the browser-tab icon
|
|
42
|
+
live per tenant, no rebuild. The static `app/icon.*` files remain the fallback.
|
|
43
|
+
|
|
10
44
|
## 1.6.3 — 2026-07-25
|
|
11
45
|
|
|
12
46
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Suman Bonakurthi",
|
package/src/record-view.tsx
CHANGED
|
@@ -171,6 +171,42 @@ export function usePageTitle(title: string, icon?: IconType) {
|
|
|
171
171
|
}, [title]);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/** Control kinds the Filter panel can render for a `filterable` field. Omitted
|
|
175
|
+
* or unknown kinds render a text input — extend this union as you add controls
|
|
176
|
+
* (e.g. `"daterange"`, `"multiselect"`). */
|
|
177
|
+
export type FilterControl =
|
|
178
|
+
| "text"
|
|
179
|
+
| "number"
|
|
180
|
+
| "date"
|
|
181
|
+
| "select"
|
|
182
|
+
| "combobox"
|
|
183
|
+
| "checkbox";
|
|
184
|
+
|
|
185
|
+
/** Per-field Filter-panel config. `filterable: true` is shorthand for
|
|
186
|
+
* `{ control: "text" }`; pass an object to pick the control and shape it, so
|
|
187
|
+
* the front end can compose a different filter form per request (Name + Code as
|
|
188
|
+
* text for one screen, a status dropdown + tag checkboxes for another). */
|
|
189
|
+
export interface FieldFilter {
|
|
190
|
+
/** Which control to render. Default `"text"`. */
|
|
191
|
+
control?: FilterControl;
|
|
192
|
+
/** Label above the control. Defaults to the field's `label`. */
|
|
193
|
+
label?: string;
|
|
194
|
+
/** Placeholder for text / number / combobox inputs. */
|
|
195
|
+
placeholder?: string;
|
|
196
|
+
/** Choices for `select` / `combobox` / `checkbox`. Falls back to the field's
|
|
197
|
+
* own `options` when omitted. */
|
|
198
|
+
options?: { value: string; label: string }[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Values collected by the Filter panel, keyed by field. Single-value controls
|
|
202
|
+
* yield a `string`; multi-select `checkbox` yields a `string[]`. This object is
|
|
203
|
+
* the contract you hand to your own query / refetch via {@link RecordView}'s
|
|
204
|
+
* `onFilter` — in per-field mode the panel gathers values but does not match
|
|
205
|
+
* rows itself, so the search is yours (client-side or server-side). */
|
|
206
|
+
export type FilterValues<T> = Partial<
|
|
207
|
+
Record<Extract<keyof T, string>, string | string[]>
|
|
208
|
+
>;
|
|
209
|
+
|
|
174
210
|
export interface RecordField<T> {
|
|
175
211
|
key: Extract<keyof T, string>;
|
|
176
212
|
label: string;
|
|
@@ -199,6 +235,13 @@ export interface RecordField<T> {
|
|
|
199
235
|
* textarea). `"number"`/`"date"` render the matching native input; a field
|
|
200
236
|
* with `options` always renders a `Select` regardless of this. */
|
|
201
237
|
input?: "text" | "number" | "date";
|
|
238
|
+
/** Expose this field in the Filter panel. When ANY field is filterable, the
|
|
239
|
+
* panel switches from the single keyword box to a labeled control per field
|
|
240
|
+
* plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
|
|
241
|
+
* choose the control (dropdown, checkbox, combobox, number, date …) so the
|
|
242
|
+
* filter form is composed per request. The panel only gathers values — wire
|
|
243
|
+
* matching through RecordView's `onFilter`. Omit to leave the field out. */
|
|
244
|
+
filterable?: boolean | FieldFilter;
|
|
202
245
|
}
|
|
203
246
|
|
|
204
247
|
/**
|
|
@@ -292,6 +335,11 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
292
335
|
/** Allow dragging column edges to resize them. Off by default — columns
|
|
293
336
|
* auto-size, and no resize handle appears on hover. */
|
|
294
337
|
resizableColumns?: boolean;
|
|
338
|
+
/** Called from the Filter panel's Search (and Clear) when fields are
|
|
339
|
+
* `filterable`. Receives the collected per-field values; run your own query
|
|
340
|
+
* or client-side filtering here. In per-field mode the panel does not match
|
|
341
|
+
* rows itself, so the behavior is entirely yours. */
|
|
342
|
+
onFilter?: (values: FilterValues<T>) => void;
|
|
295
343
|
}
|
|
296
344
|
|
|
297
345
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -313,6 +361,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
313
361
|
onEdit,
|
|
314
362
|
persistKey,
|
|
315
363
|
resizableColumns = false,
|
|
364
|
+
onFilter,
|
|
316
365
|
}: RecordViewProps<T>) {
|
|
317
366
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
318
367
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -339,6 +388,12 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
339
388
|
persistKey ? `${persistKey}::filter` : undefined,
|
|
340
389
|
"",
|
|
341
390
|
);
|
|
391
|
+
// Per-field Filter-panel values (opt-in via `field.filterable`). Kept apart
|
|
392
|
+
// from the single-keyword `filter`; persisted like the rest of the view.
|
|
393
|
+
const [filterValues, setFilterValues] = usePersistentState<FilterValues<T>>(
|
|
394
|
+
persistKey ? `${persistKey}::filterValues` : undefined,
|
|
395
|
+
{},
|
|
396
|
+
);
|
|
342
397
|
const [sort, setSort] = usePersistentState<{
|
|
343
398
|
key: string;
|
|
344
399
|
dir: "asc" | "desc";
|
|
@@ -405,6 +460,9 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
405
460
|
|
|
406
461
|
const tableFields = fields.filter((f) => !f.hideInTable);
|
|
407
462
|
const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
|
|
463
|
+
// Fields opted into per-field filtering. Non-empty → the Filter panel renders
|
|
464
|
+
// a control per field instead of the single keyword box.
|
|
465
|
+
const filterFields = fields.filter((f) => f.filterable);
|
|
408
466
|
|
|
409
467
|
// The primary "Name" column renders the record's name field, which is hidden
|
|
410
468
|
// as a regular column (hideInTable) because it shows here. Mirror its
|
|
@@ -1007,19 +1065,119 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1007
1065
|
</Dropdown>
|
|
1008
1066
|
)}
|
|
1009
1067
|
<Dropdown label="Filter" icon={<ListFilter className="size-3.5 text-amber-500" />}>
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1068
|
+
{filterFields.length > 0 ? (
|
|
1069
|
+
// Per-field mode: a labeled control per `filterable` field, plus
|
|
1070
|
+
// Search / Clear. The panel only gathers values — matching is the
|
|
1071
|
+
// consumer's job via `onFilter` (see the field's `filterable`).
|
|
1072
|
+
<>
|
|
1073
|
+
<DropdownLabel>Filter</DropdownLabel>
|
|
1074
|
+
<div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
|
|
1075
|
+
{filterFields.map((f) => {
|
|
1076
|
+
const cfg: FieldFilter =
|
|
1077
|
+
typeof f.filterable === "object" ? f.filterable : {};
|
|
1078
|
+
const control = cfg.control ?? "text";
|
|
1079
|
+
const label = cfg.label ?? f.label;
|
|
1080
|
+
const opts = cfg.options ?? f.options ?? [];
|
|
1081
|
+
const raw = filterValues[f.key];
|
|
1082
|
+
const setVal = (v: string | string[]) =>
|
|
1083
|
+
setFilterValues((prev) => ({ ...prev, [f.key]: v }));
|
|
1084
|
+
return (
|
|
1085
|
+
<div key={f.key} className="flex flex-col gap-1">
|
|
1086
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
1087
|
+
{label}
|
|
1088
|
+
</label>
|
|
1089
|
+
{control === "select" || control === "combobox" ? (
|
|
1090
|
+
<Select
|
|
1091
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1092
|
+
onValueChange={setVal}
|
|
1093
|
+
options={opts}
|
|
1094
|
+
ariaLabel={label}
|
|
1095
|
+
placeholder={
|
|
1096
|
+
cfg.placeholder ?? `Any ${label.toLowerCase()}`
|
|
1097
|
+
}
|
|
1098
|
+
className="w-full"
|
|
1099
|
+
/>
|
|
1100
|
+
) : control === "checkbox" ? (
|
|
1101
|
+
<div className="flex flex-col gap-1">
|
|
1102
|
+
{opts.map((o) => {
|
|
1103
|
+
const arr = Array.isArray(raw) ? raw : [];
|
|
1104
|
+
const on = arr.includes(o.value);
|
|
1105
|
+
return (
|
|
1106
|
+
<label
|
|
1107
|
+
key={o.value}
|
|
1108
|
+
className="flex items-center gap-2 text-sm"
|
|
1109
|
+
>
|
|
1110
|
+
<input
|
|
1111
|
+
type="checkbox"
|
|
1112
|
+
checked={on}
|
|
1113
|
+
onChange={() =>
|
|
1114
|
+
setVal(
|
|
1115
|
+
on
|
|
1116
|
+
? arr.filter((v) => v !== o.value)
|
|
1117
|
+
: [...arr, o.value],
|
|
1118
|
+
)
|
|
1119
|
+
}
|
|
1120
|
+
/>
|
|
1121
|
+
{o.label}
|
|
1122
|
+
</label>
|
|
1123
|
+
);
|
|
1124
|
+
})}
|
|
1125
|
+
</div>
|
|
1126
|
+
) : (
|
|
1127
|
+
<Input
|
|
1128
|
+
type={
|
|
1129
|
+
control === "number"
|
|
1130
|
+
? "number"
|
|
1131
|
+
: control === "date"
|
|
1132
|
+
? "date"
|
|
1133
|
+
: "text"
|
|
1134
|
+
}
|
|
1135
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1136
|
+
onChange={(e) => setVal(e.target.value)}
|
|
1137
|
+
placeholder={cfg.placeholder ?? "Contains…"}
|
|
1138
|
+
aria-label={label}
|
|
1139
|
+
className="h-8"
|
|
1140
|
+
/>
|
|
1141
|
+
)}
|
|
1142
|
+
</div>
|
|
1143
|
+
);
|
|
1144
|
+
})}
|
|
1145
|
+
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
|
|
1146
|
+
<Button
|
|
1147
|
+
onClick={() => {
|
|
1148
|
+
setFilterValues({});
|
|
1149
|
+
onFilter?.({});
|
|
1150
|
+
}}
|
|
1151
|
+
>
|
|
1152
|
+
Clear
|
|
1153
|
+
</Button>
|
|
1154
|
+
<Button
|
|
1155
|
+
variant="primary"
|
|
1156
|
+
onClick={() => onFilter?.(filterValues)}
|
|
1157
|
+
>
|
|
1158
|
+
<Search className="size-3.5" />
|
|
1159
|
+
Search
|
|
1160
|
+
</Button>
|
|
1161
|
+
</div>
|
|
1162
|
+
</div>
|
|
1163
|
+
</>
|
|
1164
|
+
) : (
|
|
1165
|
+
<>
|
|
1166
|
+
<DropdownLabel>Filter by keyword</DropdownLabel>
|
|
1167
|
+
<div className="p-3">
|
|
1168
|
+
<div className="relative">
|
|
1169
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
1170
|
+
<Input
|
|
1171
|
+
value={filter}
|
|
1172
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
1173
|
+
placeholder="Contains…"
|
|
1174
|
+
aria-label="Filter"
|
|
1175
|
+
className="h-8 pl-9"
|
|
1176
|
+
/>
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
</>
|
|
1180
|
+
)}
|
|
1023
1181
|
</Dropdown>
|
|
1024
1182
|
|
|
1025
1183
|
<Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
|
package/template/.env.example
CHANGED
|
@@ -12,10 +12,11 @@ NEXT_PUBLIC_APP_DESCRIPTION="Your app description for search engines and social
|
|
|
12
12
|
NEXT_PUBLIC_APP_URL="https://vui.viliha.com"
|
|
13
13
|
|
|
14
14
|
# Runtime branding (multi-tenant / white-label). Point this at a JSON endpoint
|
|
15
|
-
# returning any of { name, tagline, description, logoUrl, company,
|
|
16
|
-
# BrandProvider fetches it on load and overrides the values above —
|
|
17
|
-
# tab title — live, no rebuild. (Or seed
|
|
18
|
-
# loader, or call useBrand().setBrand(…) once
|
|
15
|
+
# returning any of { name, tagline, description, logoUrl, faviconUrl, company,
|
|
16
|
+
# companyUrl }; BrandProvider fetches it on load and overrides the values above —
|
|
17
|
+
# including the tab title and favicon — live, no rebuild. (Or seed
|
|
18
|
+
# <BrandProvider initial={…}> from a loader, or call useBrand().setBrand(…) once
|
|
19
|
+
# your API responds.)
|
|
19
20
|
# NEXT_PUBLIC_BRAND_URL="https://api.example.com/branding"
|
|
20
21
|
|
|
21
22
|
NEXT_PUBLIC_COMPANY_NAME="VILIHA PTE. LTD."
|
|
@@ -28,6 +29,10 @@ NEXT_PUBLIC_LICENSE="MIT Licensed"
|
|
|
28
29
|
# mark. e.g. drop public/logo.svg and set:
|
|
29
30
|
# NEXT_PUBLIC_LOGO_URL="/logo.svg"
|
|
30
31
|
|
|
32
|
+
# Optional — browser-tab icon (favicon). Overrides the static app/icon.* files;
|
|
33
|
+
# falls back to them when unset. Serve from /public or use an absolute URL.
|
|
34
|
+
# NEXT_PUBLIC_FAVICON_URL="/favicon.png"
|
|
35
|
+
|
|
31
36
|
# Override the entire footer line at once (takes precedence over the vars above):
|
|
32
37
|
# NEXT_PUBLIC_FOOTER_NOTICE="© 2026 Acme Inc. · All rights reserved"
|
|
33
38
|
|
|
@@ -1,26 +1,83 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
GlobeIcon as Globe,
|
|
6
|
-
} from "@radix-ui/react-icons";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { CodeIcon as Hash, GlobeIcon as Globe } from "@radix-ui/react-icons";
|
|
7
5
|
|
|
8
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
RecordView,
|
|
8
|
+
type FilterValues,
|
|
9
|
+
type RecordField,
|
|
10
|
+
} from "@viliha/vui-ui/record-view";
|
|
9
11
|
import { regions, type Region } from "@/lib/mock-data";
|
|
10
12
|
|
|
13
|
+
// `filterable` opts a field into the Filter panel. With any field filterable the
|
|
14
|
+
// panel shows a labeled control per field + Search/Clear instead of the single
|
|
15
|
+
// keyword box. `true` = a text input; pass a config to pick the control.
|
|
11
16
|
const fields: RecordField<Region>[] = [
|
|
12
|
-
{
|
|
13
|
-
|
|
17
|
+
{
|
|
18
|
+
key: "name",
|
|
19
|
+
label: "Name",
|
|
20
|
+
editable: true,
|
|
21
|
+
required: true,
|
|
22
|
+
group: "General",
|
|
23
|
+
hideInTable: true,
|
|
24
|
+
filterable: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "code",
|
|
28
|
+
label: "Code",
|
|
29
|
+
icon: Hash,
|
|
30
|
+
editable: true,
|
|
31
|
+
group: "General",
|
|
32
|
+
filterable: { control: "text", placeholder: "e.g. APAC" },
|
|
33
|
+
},
|
|
14
34
|
];
|
|
15
35
|
|
|
16
36
|
export function RegionsTable() {
|
|
37
|
+
const [all, setAll] = useState<Region[]>(regions);
|
|
38
|
+
const [filters, setFilters] = useState<FilterValues<Region>>({});
|
|
39
|
+
|
|
40
|
+
// Demo: match the per-field values client-side. The component does NOT filter
|
|
41
|
+
// in per-field mode — `onFilter` hands you the values; swap this for a server
|
|
42
|
+
// query in a real app. ponytail: naive contains-match, fine for demo data.
|
|
43
|
+
const rows = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
all.filter((r) =>
|
|
46
|
+
Object.entries(filters).every(([k, v]) => {
|
|
47
|
+
const needle = String(v ?? "").toLowerCase();
|
|
48
|
+
return (
|
|
49
|
+
!needle ||
|
|
50
|
+
String(r[k as keyof Region]).toLowerCase().includes(needle)
|
|
51
|
+
);
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
[all, filters],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Reconcile edits/adds/deletes made on the filtered view back into `all`.
|
|
58
|
+
const handleChange = (next: Region[]) => {
|
|
59
|
+
setAll((prev) => {
|
|
60
|
+
const nextById = new Map(next.map((r) => [r.id, r] as const));
|
|
61
|
+
const visible = new Set(rows.map((r) => r.id));
|
|
62
|
+
const kept = prev
|
|
63
|
+
.filter((r) => !visible.has(r.id) || nextById.has(r.id)) // drop deleted
|
|
64
|
+
.map((r) => nextById.get(r.id) ?? r); // apply edits
|
|
65
|
+
const added = next.filter((r) => !prev.some((p) => p.id === r.id));
|
|
66
|
+
return [...kept, ...added];
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
17
70
|
return (
|
|
18
71
|
<RecordView
|
|
19
72
|
title="Regions"
|
|
20
73
|
singular="Region"
|
|
21
74
|
icon={Globe}
|
|
75
|
+
persistKey="/system/regions"
|
|
22
76
|
fields={fields}
|
|
23
|
-
initialData={
|
|
77
|
+
initialData={rows}
|
|
78
|
+
data={rows}
|
|
79
|
+
onDataChange={handleChange}
|
|
80
|
+
onFilter={setFilters}
|
|
24
81
|
getPrimary={(row) => ({
|
|
25
82
|
title: row.name,
|
|
26
83
|
subtitle: row.code,
|
|
@@ -26,6 +26,9 @@ export type Brand = {
|
|
|
26
26
|
description: string;
|
|
27
27
|
/** Overrides NEXT_PUBLIC_LOGO_URL at runtime; unset → the built-in mark. */
|
|
28
28
|
logoUrl?: string;
|
|
29
|
+
/** Browser-tab icon. Overrides NEXT_PUBLIC_FAVICON_URL at runtime; unset →
|
|
30
|
+
* the static app/icon.* file convention. */
|
|
31
|
+
faviconUrl?: string;
|
|
29
32
|
company: string;
|
|
30
33
|
companyUrl: string;
|
|
31
34
|
};
|
|
@@ -35,6 +38,7 @@ const DEFAULT_BRAND: Brand = {
|
|
|
35
38
|
tagline: SITE.tagline,
|
|
36
39
|
description: SITE.description,
|
|
37
40
|
logoUrl: process.env.NEXT_PUBLIC_LOGO_URL || undefined,
|
|
41
|
+
faviconUrl: process.env.NEXT_PUBLIC_FAVICON_URL || undefined,
|
|
38
42
|
company: SITE.company,
|
|
39
43
|
companyUrl: SITE.companyUrl,
|
|
40
44
|
};
|
|
@@ -106,6 +110,32 @@ export function BrandProvider({
|
|
|
106
110
|
return () => cancelAnimationFrame(id);
|
|
107
111
|
}, [pathname, brand.name, brand.tagline]);
|
|
108
112
|
|
|
113
|
+
// Swap the browser-tab icon when the brand provides one (env default or API).
|
|
114
|
+
// The static app/icon.* links are the pre-JS fallback; here we point every
|
|
115
|
+
// icon <link> at the brand favicon and restore them if it changes/unmounts.
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
const href = brand.faviconUrl;
|
|
118
|
+
if (!href) return; // no override — keep the static file-convention icons
|
|
119
|
+
const links = Array.from(
|
|
120
|
+
document.querySelectorAll<HTMLLinkElement>("link[rel~='icon']"),
|
|
121
|
+
);
|
|
122
|
+
if (links.length === 0) {
|
|
123
|
+
const link = document.createElement("link");
|
|
124
|
+
link.rel = "icon";
|
|
125
|
+
document.head.appendChild(link);
|
|
126
|
+
links.push(link);
|
|
127
|
+
}
|
|
128
|
+
const prev = links.map((l) => l.getAttribute("href"));
|
|
129
|
+
links.forEach((l) => l.setAttribute("href", href));
|
|
130
|
+
return () => {
|
|
131
|
+
links.forEach((l, i) => {
|
|
132
|
+
const p = prev[i];
|
|
133
|
+
if (p == null) l.remove(); // was created/absent → drop it
|
|
134
|
+
else l.setAttribute("href", p);
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
}, [brand.faviconUrl]);
|
|
138
|
+
|
|
109
139
|
return (
|
|
110
140
|
<BrandContext.Provider value={{ brand, setBrand }}>
|
|
111
141
|
{children}
|