@protobase/ui 0.14.4 → 0.14.5
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/dist/activity-chart/activity-chart.js +1 -1
- package/dist/app/app.d.ts +2 -1
- package/dist/app/app.js +10 -3
- package/dist/app/displays/display-controls.js +1 -1
- package/dist/app/embedded-list/embedded-list.js +35 -27
- package/dist/app/index.html +10 -0
- package/dist/app/list-page.js +20 -3
- package/dist/app/menu-icons.js +2 -1
- package/dist/app/named-icon.js +3 -2
- package/dist/app/pages/blocks/field.js +2 -2
- package/dist/app/pages/blocks/progress.js +1 -1
- package/dist/app/pages/blocks/show.js +1 -1
- package/dist/app/pages/blocks/stat.js +2 -2
- package/dist/app/pages/blocks/table.js +1 -1
- package/dist/app/searches/save-search-button.js +44 -0
- package/dist/app/searches/saved-search-section.js +97 -0
- package/dist/app/searches/saved-search.js +94 -0
- package/dist/app/searches/saved-searches.js +107 -0
- package/dist/app/searches/search-controls.js +64 -0
- package/dist/app/searches/use-filter-chips.js +27 -0
- package/dist/app/searches/use-save-search.js +70 -0
- package/dist/app/searches/use-saved-searches.js +37 -0
- package/dist/app-shell/app-shell.js +3 -2
- package/dist/app-shell/drawer.js +1 -1
- package/dist/app-shell/logo.js +2 -2
- package/dist/app-shell/profile-menu.js +10 -2
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/theme-switch.js +41 -0
- package/dist/auth/auth-gate.js +0 -9
- package/dist/data-grid/data-grid.js +1 -1
- package/dist/live/use-filter-config.js +2 -6
- package/dist/live/view-fields.js +8 -1
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +1 -1
- package/dist/primitives/responsive-dialog.d.ts +1 -2
- package/dist/primitives/responsive-dialog.js +11 -13
- package/dist/primitives/segmented.js +6 -2
- package/dist/primitives/sheet.js +39 -39
- package/dist/shell/assets/app-DxpTYgsV.js +66 -0
- package/dist/shell/assets/index-B0V1UXFn.css +2 -0
- package/dist/shell/assets/{index-DhgTXlZ3.js → index-C_ilu7LX.js} +1 -1
- package/dist/shell/assets/{maplibre-gl-CKQN3M1I.js → maplibre-gl-DPsuz0sk.js} +1 -1
- package/dist/shell/assets/{record-map-CzJ60-ux.js → record-map-grRO6rSC.js} +2 -2
- package/dist/shell/assets/{records-map-dch_o1ug.js → records-map-DjUTonTE.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-C-qDEeIK.js → shared-protobase-ui-BkgB1XZX.js} +1 -1
- package/dist/shell/index.html +14 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/styles.css +5 -2
- package/dist/theme/theme-preference.js +23 -0
- package/dist/theme/theme-provider.js +43 -0
- package/dist/theme.css +4 -0
- package/dist/tokens.css +2 -0
- package/package.json +4 -5
- package/dist/shell/assets/app-CyW0VzOi.js +0 -66
- package/dist/shell/assets/index-DthLyPYI.css +0 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { PageHeader } from "../../app-shell/page-header.js";
|
|
3
|
+
import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
6
|
+
import { AppLink } from "../use-app-address.js";
|
|
7
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
8
|
+
import { can } from "../permissions.js";
|
|
9
|
+
import { useDeleteFlow } from "../delete/use-delete-flow.js";
|
|
10
|
+
import { useSavedSearches } from "./use-saved-searches.js";
|
|
11
|
+
import { SavedSearchSection } from "./saved-search-section.js";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
14
|
+
//#region src/app/searches/saved-searches.tsx
|
|
15
|
+
/**
|
|
16
|
+
* The list page of a view with `searchesOf`: each of the user's saved searches, with what it finds (`SavedSearchSection`),
|
|
17
|
+
* in the order of the view's list; until they have loaded, or when there are none, the page says so under its title.
|
|
18
|
+
*/
|
|
19
|
+
const SavedSearches = ({ model, view }) => {
|
|
20
|
+
const { resources, views } = useAdminMeta();
|
|
21
|
+
const of = view.searchesOf;
|
|
22
|
+
const target = resources[of.resource];
|
|
23
|
+
const targetView = views[of.resource];
|
|
24
|
+
const saved = useSavedSearches(model.name, true);
|
|
25
|
+
const permissions = usePermissions(model.name);
|
|
26
|
+
const remove = useDeleteFlow({
|
|
27
|
+
model,
|
|
28
|
+
view
|
|
29
|
+
});
|
|
30
|
+
const plural = view.names?.plural ?? humanize(model.name);
|
|
31
|
+
const titleField = recordTitleField(model, view);
|
|
32
|
+
const frame = (body) => /* @__PURE__ */ jsxs("div", {
|
|
33
|
+
className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
|
|
34
|
+
children: [
|
|
35
|
+
remove.dialogs,
|
|
36
|
+
/* @__PURE__ */ jsx(PageHeader, {
|
|
37
|
+
title: plural,
|
|
38
|
+
subtitle: view.help
|
|
39
|
+
}),
|
|
40
|
+
body
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
if (!target) return frame(/* @__PURE__ */ jsxs(Notice, {
|
|
44
|
+
title: "Not found",
|
|
45
|
+
children: [
|
|
46
|
+
"There is no resource called ",
|
|
47
|
+
of.resource,
|
|
48
|
+
"."
|
|
49
|
+
]
|
|
50
|
+
}));
|
|
51
|
+
if (saved.error) return frame(/* @__PURE__ */ jsx(ErrorBanner, {
|
|
52
|
+
title: `Could not load your ${plural.toLowerCase()}`,
|
|
53
|
+
error: saved.error
|
|
54
|
+
}));
|
|
55
|
+
if (!saved.data) return frame(/* @__PURE__ */ jsx("div", {
|
|
56
|
+
className: "flex flex-col gap-3",
|
|
57
|
+
children: Array.from({ length: 2 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-56" }, index))
|
|
58
|
+
}));
|
|
59
|
+
if (saved.data.length === 0) {
|
|
60
|
+
const searched = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
|
|
61
|
+
return frame(/* @__PURE__ */ jsxs("p", {
|
|
62
|
+
className: "py-8 text-center text-[13px] text-muted-foreground",
|
|
63
|
+
children: [
|
|
64
|
+
"No ",
|
|
65
|
+
plural.toLowerCase(),
|
|
66
|
+
" yet. Filter the",
|
|
67
|
+
" ",
|
|
68
|
+
/* @__PURE__ */ jsx(AppLink, {
|
|
69
|
+
to: {
|
|
70
|
+
kind: "list",
|
|
71
|
+
resource: target.name
|
|
72
|
+
},
|
|
73
|
+
className: "font-medium text-primary-text hover:underline",
|
|
74
|
+
children: searched
|
|
75
|
+
}),
|
|
76
|
+
" ",
|
|
77
|
+
"and save the search."
|
|
78
|
+
]
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
return frame(/* @__PURE__ */ jsx("div", {
|
|
82
|
+
className: "flex flex-col gap-4",
|
|
83
|
+
children: saved.data.map((row) => {
|
|
84
|
+
const key = encodeKey(recordKey(model, row));
|
|
85
|
+
const title = String(row[titleField] ?? key);
|
|
86
|
+
const onDelete = can(permissions, "delete") ? () => void remove.start({
|
|
87
|
+
key,
|
|
88
|
+
title,
|
|
89
|
+
etag: row.etag,
|
|
90
|
+
snapshot: row
|
|
91
|
+
}) : void 0;
|
|
92
|
+
return /* @__PURE__ */ jsx(SavedSearchSection, {
|
|
93
|
+
model,
|
|
94
|
+
view,
|
|
95
|
+
of,
|
|
96
|
+
target,
|
|
97
|
+
targetView,
|
|
98
|
+
row,
|
|
99
|
+
recordKey: key,
|
|
100
|
+
title,
|
|
101
|
+
onDelete
|
|
102
|
+
}, key);
|
|
103
|
+
})
|
|
104
|
+
}));
|
|
105
|
+
};
|
|
106
|
+
//#endregion
|
|
107
|
+
export { SavedSearches };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
|
+
import { Switch } from "../../primitives/switch.js";
|
|
5
|
+
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
6
|
+
import { useId } from "react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { useMutation } from "@tanstack/react-query";
|
|
9
|
+
import { enumLabel } from "@protobase/schema";
|
|
10
|
+
//#region src/app/searches/search-controls.tsx
|
|
11
|
+
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
12
|
+
const Control = ({ model, view, name, value, onChange }) => {
|
|
13
|
+
const id = useId();
|
|
14
|
+
const field = model.fields[name];
|
|
15
|
+
const hints = view.fields[name];
|
|
16
|
+
const label = hints?.label ?? humanize(name);
|
|
17
|
+
if (field?.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
|
|
18
|
+
checked: value === true,
|
|
19
|
+
onChange,
|
|
20
|
+
label
|
|
21
|
+
});
|
|
22
|
+
if (field?.type !== "enum") throw new Error(`Not implemented: "${name}" is ${field?.type ?? "unknown"}, and a saved search's controls are enums or booleans`);
|
|
23
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
24
|
+
htmlFor: id,
|
|
25
|
+
className: "flex items-center gap-2 text-[13px] text-muted-foreground",
|
|
26
|
+
children: [label, /* @__PURE__ */ jsx("select", {
|
|
27
|
+
id,
|
|
28
|
+
value: String(value ?? ""),
|
|
29
|
+
onChange: (event) => onChange(event.target.value),
|
|
30
|
+
className: "h-8 rounded-md border border-border-strong bg-background px-2 text-[13px] text-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
|
|
31
|
+
children: (field.enumValues ?? []).map((option) => /* @__PURE__ */ jsx("option", {
|
|
32
|
+
value: option,
|
|
33
|
+
children: enumLabel(option, hints?.valueLabels)
|
|
34
|
+
}, option))
|
|
35
|
+
})]
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
/** A saved search's controls, each saved as soon as it changes; a refused change says why and shows the search as stored. */
|
|
39
|
+
const SearchControls = ({ model, view, recordKey, row, fields }) => {
|
|
40
|
+
const client = useClient();
|
|
41
|
+
const toast = useToast();
|
|
42
|
+
const refresh = useRefreshResource();
|
|
43
|
+
const change = useMutation({
|
|
44
|
+
mutationFn: (patch) => client.update(model.name, recordKey, patch, row.etag ?? "*"),
|
|
45
|
+
onError: (error) => toast.show({
|
|
46
|
+
state: "error",
|
|
47
|
+
title: "Could not change the search",
|
|
48
|
+
description: message(error)
|
|
49
|
+
}),
|
|
50
|
+
onSettled: () => refresh(model.name)
|
|
51
|
+
});
|
|
52
|
+
return /* @__PURE__ */ jsx("div", {
|
|
53
|
+
className: "flex flex-wrap items-center gap-3",
|
|
54
|
+
children: fields.map((name) => /* @__PURE__ */ jsx(Control, {
|
|
55
|
+
model,
|
|
56
|
+
view,
|
|
57
|
+
name,
|
|
58
|
+
value: change.isPending && name in (change.variables ?? {}) ? change.variables[name] : row[name],
|
|
59
|
+
onChange: (value) => change.mutate({ [name]: value })
|
|
60
|
+
}, name))
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { SearchControls };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { fetchLabels } from "../../live/relation-labels.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { chosenRelations, filterChips } from "./saved-search.js";
|
|
6
|
+
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
//#region src/app/searches/use-filter-chips.ts
|
|
8
|
+
/** A saved filter of `model`'s list as chips, with the records its relation facets choose named once they have loaded. */
|
|
9
|
+
const useFilterChips = (model, text) => {
|
|
10
|
+
const client = useClient();
|
|
11
|
+
const queryClient = useQueryClient();
|
|
12
|
+
const { resources, views } = useAdminMeta();
|
|
13
|
+
const view = views[model.name];
|
|
14
|
+
const chosen = Object.entries(chosenRelations(model, view, text));
|
|
15
|
+
const named = useQueries({ queries: chosen.map(([field, ids]) => {
|
|
16
|
+
const target = resources[model.fields[field].relation.resource];
|
|
17
|
+
return {
|
|
18
|
+
queryKey: keys.labels(`chips:${model.name}.${field}`, ids.join(",")),
|
|
19
|
+
enabled: target !== void 0,
|
|
20
|
+
queryFn: async () => Object.fromEntries(await fetchLabels(queryClient, client, target, ids, views[target.name]))
|
|
21
|
+
};
|
|
22
|
+
}) });
|
|
23
|
+
const names = Object.fromEntries(chosen.flatMap(([field], index) => named[index]?.data ? [[field, named[index].data]] : []));
|
|
24
|
+
return filterChips(model, view, text, names);
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { useFilterChips };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
3
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
6
|
+
import { can } from "../permissions.js";
|
|
7
|
+
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
8
|
+
import { useSavedSearches } from "./use-saved-searches.js";
|
|
9
|
+
import { useMutation } from "@tanstack/react-query";
|
|
10
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
11
|
+
//#region src/app/searches/use-save-search.ts
|
|
12
|
+
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
13
|
+
/**
|
|
14
|
+
* A list page's "Save search" button for its `filter`: `save` for someone who can list, create and delete the saved
|
|
15
|
+
* searches, `sign-in` for a visitor their resource asks to sign in (`prompt`), nothing otherwise. A press saves the
|
|
16
|
+
* filter as a search named `name`, or takes the saved one off again.
|
|
17
|
+
*/
|
|
18
|
+
const useSaveSearch = (search, filter, name) => {
|
|
19
|
+
const meta = useAdminMeta();
|
|
20
|
+
const client = useClient();
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const refresh = useRefreshResource();
|
|
23
|
+
const { go } = useAppAddress();
|
|
24
|
+
const { resource } = search;
|
|
25
|
+
const model = meta.resources[resource];
|
|
26
|
+
const of = meta.views[resource]?.searchesOf;
|
|
27
|
+
const permissions = meta.permissions[resource];
|
|
28
|
+
const usable = model !== void 0 && of !== void 0 && permissions !== void 0 && can(permissions, "create") && can(permissions, "delete");
|
|
29
|
+
const prompt = meta.signIn?.[resource]?.signIn;
|
|
30
|
+
const saved = useSavedSearches(resource, usable);
|
|
31
|
+
const current = of && saved.data?.find((row) => row[of.filter] === filter);
|
|
32
|
+
const plural = meta.views[resource]?.names?.plural ?? "saved searches";
|
|
33
|
+
const toggle = useMutation({
|
|
34
|
+
mutationFn: async (existing) => {
|
|
35
|
+
if (existing) await client.remove(resource, encodeKey(recordKey(model, existing)));
|
|
36
|
+
else await client.create(resource, {
|
|
37
|
+
[of.filter]: filter,
|
|
38
|
+
...of.name && { [of.name]: name }
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
onSuccess: (_, existing) => {
|
|
42
|
+
if (!existing) toast.show({
|
|
43
|
+
state: "success",
|
|
44
|
+
title: "Search saved",
|
|
45
|
+
action: {
|
|
46
|
+
label: `Open ${plural.toLowerCase()}`,
|
|
47
|
+
onClick: () => go({
|
|
48
|
+
kind: "list",
|
|
49
|
+
resource
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
onError: (error, existing) => toast.show({
|
|
55
|
+
state: "error",
|
|
56
|
+
title: existing ? "Could not take the search off" : "Could not save the search",
|
|
57
|
+
description: message(error)
|
|
58
|
+
}),
|
|
59
|
+
onSettled: () => refresh(resource)
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
mode: usable ? "save" : prompt ? "sign-in" : void 0,
|
|
63
|
+
prompt,
|
|
64
|
+
saved: current !== void 0,
|
|
65
|
+
ready: saved.data !== void 0 && !toggle.isPending,
|
|
66
|
+
toggle: () => toggle.mutate(current)
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { useSaveSearch };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { defaultOrderBy } from "../list-order.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
//#region src/app/searches/use-saved-searches.ts
|
|
7
|
+
/** Where the user's saved searches of `resource` are cached; a list key, so a write to the resource refreshes it. */
|
|
8
|
+
const savedSearchesKey = (resource) => keys.list(resource, "", "", 500, "saved-searches");
|
|
9
|
+
/**
|
|
10
|
+
* The signed-in user's saved searches, in the order of their view's list: every one, page by page, as the API lists only
|
|
11
|
+
* a user's own records of a resource with an owner. Loads only while `enabled`.
|
|
12
|
+
*/
|
|
13
|
+
const useSavedSearches = (resource, enabled) => {
|
|
14
|
+
const client = useClient();
|
|
15
|
+
const { resources, views } = useAdminMeta();
|
|
16
|
+
const model = resources[resource];
|
|
17
|
+
return useQuery({
|
|
18
|
+
queryKey: savedSearchesKey(resource),
|
|
19
|
+
enabled: enabled && model !== void 0,
|
|
20
|
+
queryFn: async () => {
|
|
21
|
+
const rows = [];
|
|
22
|
+
let pageToken;
|
|
23
|
+
do {
|
|
24
|
+
const page = await client.list(resource, {
|
|
25
|
+
pageSize: 500,
|
|
26
|
+
orderBy: defaultOrderBy(model, views[resource]),
|
|
27
|
+
...pageToken && { pageToken }
|
|
28
|
+
});
|
|
29
|
+
rows.push(...page.items);
|
|
30
|
+
pageToken = page.nextPageToken || void 0;
|
|
31
|
+
} while (pageToken);
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { savedSearchesKey, useSavedSearches };
|
|
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
9
9
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
10
|
//#region src/app-shell/app-shell.tsx
|
|
11
11
|
const drawerId = "app-drawer";
|
|
12
|
-
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, onSignIn, organizations, roles, search, className, children }) => {
|
|
12
|
+
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, onSignIn, organizations, roles, theme, search, className, children }) => {
|
|
13
13
|
const desktop = useIsDesktop();
|
|
14
14
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
15
15
|
useEffect(() => {
|
|
@@ -34,7 +34,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
|
|
|
34
34
|
onSignOut,
|
|
35
35
|
onSignIn,
|
|
36
36
|
organizations,
|
|
37
|
-
roles
|
|
37
|
+
roles,
|
|
38
|
+
theme
|
|
38
39
|
});
|
|
39
40
|
return /* @__PURE__ */ jsx(PageScrollContext.Provider, {
|
|
40
41
|
value: setPageScroll,
|
package/dist/app-shell/drawer.js
CHANGED
|
@@ -10,7 +10,7 @@ const Drawer = ({ id, open, onClose, children }) => {
|
|
|
10
10
|
return /* @__PURE__ */ jsxs("div", {
|
|
11
11
|
className: "fixed inset-0 z-50 md:hidden",
|
|
12
12
|
children: [/* @__PURE__ */ jsx("div", {
|
|
13
|
-
className: "absolute inset-0 bg-
|
|
13
|
+
className: "absolute inset-0 bg-backdrop/40",
|
|
14
14
|
onClick: onClose,
|
|
15
15
|
"aria-hidden": true
|
|
16
16
|
}), /* @__PURE__ */ jsx("div", {
|
package/dist/app-shell/logo.js
CHANGED
|
@@ -15,14 +15,14 @@ const LogoMark = ({ className }) => /* @__PURE__ */ jsxs("svg", {
|
|
|
15
15
|
/* @__PURE__ */ jsx("path", {
|
|
16
16
|
d: "M7 17V7h5.2a3 3 0 0 1 0 6H7",
|
|
17
17
|
fill: "none",
|
|
18
|
-
|
|
18
|
+
className: "stroke-primary-foreground",
|
|
19
19
|
strokeWidth: "2",
|
|
20
20
|
strokeLinecap: "round",
|
|
21
21
|
strokeLinejoin: "round"
|
|
22
22
|
}),
|
|
23
23
|
/* @__PURE__ */ jsx("path", {
|
|
24
24
|
d: "M12.5 17h4.5",
|
|
25
|
-
|
|
25
|
+
className: "stroke-primary-foreground",
|
|
26
26
|
strokeOpacity: "0.55",
|
|
27
27
|
strokeWidth: "2",
|
|
28
28
|
strokeLinecap: "round"
|
|
@@ -2,13 +2,14 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { Avatar } from "../primitives/avatar.js";
|
|
3
3
|
import { Badge } from "../primitives/badge.js";
|
|
4
4
|
import { SwitchGroup } from "./switch-group.js";
|
|
5
|
+
import { ThemeSwitch } from "./theme-switch.js";
|
|
5
6
|
import { useEffect, useRef, useState } from "react";
|
|
6
7
|
import { ChevronsUpDown, LogOut } from "lucide-react";
|
|
7
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
9
|
//#region src/app-shell/profile-menu.tsx
|
|
9
10
|
const menuRow = "flex min-h-11 w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-muted-foreground hover:bg-muted md:min-h-0";
|
|
10
11
|
/** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
|
|
11
|
-
const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles }) => {
|
|
12
|
+
const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles, theme }) => {
|
|
12
13
|
const activeRole = roles?.roles.find((role) => role.name === roles.active);
|
|
13
14
|
const [local, setLocal] = useState(false);
|
|
14
15
|
const open = controlled ?? local;
|
|
@@ -112,7 +113,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
|
|
|
112
113
|
}
|
|
113
114
|
}),
|
|
114
115
|
items.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
115
|
-
className: cn(onSignOut && "border-b"),
|
|
116
|
+
className: cn((theme || onSignOut) && "border-b"),
|
|
116
117
|
children: items.map((item) => {
|
|
117
118
|
const Icon = item.icon;
|
|
118
119
|
return /* @__PURE__ */ jsxs("a", {
|
|
@@ -134,6 +135,13 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
|
|
|
134
135
|
}, item.id);
|
|
135
136
|
})
|
|
136
137
|
}),
|
|
138
|
+
theme && /* @__PURE__ */ jsx("div", {
|
|
139
|
+
className: cn(onSignOut && "border-b"),
|
|
140
|
+
children: /* @__PURE__ */ jsx(ThemeSwitch, {
|
|
141
|
+
theme,
|
|
142
|
+
className: cn(menuRow, "hover:bg-transparent")
|
|
143
|
+
})
|
|
144
|
+
}),
|
|
137
145
|
onSignOut && /* @__PURE__ */ jsxs("button", {
|
|
138
146
|
type: "button",
|
|
139
147
|
role: "menuitem",
|
|
@@ -8,7 +8,7 @@ import { SidebarGroup } from "./sidebar-group.js";
|
|
|
8
8
|
import { LogIn } from "lucide-react";
|
|
9
9
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
10
|
//#region src/app-shell/sidebar.tsx
|
|
11
|
-
const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, sticky }) => {
|
|
11
|
+
const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, theme, sticky }) => {
|
|
12
12
|
const expanding = mode === "icon-expand";
|
|
13
13
|
const expanded = expanding && forceExpanded;
|
|
14
14
|
const inline = isTextMode(mode) || expanding;
|
|
@@ -56,7 +56,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
|
|
|
56
56
|
onNavigate,
|
|
57
57
|
onSignOut,
|
|
58
58
|
organizations,
|
|
59
|
-
roles
|
|
59
|
+
roles,
|
|
60
|
+
theme
|
|
60
61
|
}) : onSignIn && /* @__PURE__ */ jsxs("button", {
|
|
61
62
|
type: "button",
|
|
62
63
|
onClick: onSignIn,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Segmented } from "../primitives/segmented.js";
|
|
2
|
+
import { Monitor, Moon, Sun, SunMoon } from "lucide-react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app-shell/theme-switch.tsx
|
|
5
|
+
const options = [
|
|
6
|
+
{
|
|
7
|
+
value: "light",
|
|
8
|
+
label: "Light",
|
|
9
|
+
icon: Sun
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
value: "dark",
|
|
13
|
+
label: "Dark",
|
|
14
|
+
icon: Moon
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
value: "system",
|
|
18
|
+
label: "System",
|
|
19
|
+
icon: Monitor
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
/** The profile menu's row that picks light, dark or the system's theme; the menu stays open to show the result. */
|
|
23
|
+
const ThemeSwitch = ({ theme, className }) => /* @__PURE__ */ jsxs("div", {
|
|
24
|
+
className,
|
|
25
|
+
children: [
|
|
26
|
+
/* @__PURE__ */ jsx(SunMoon, { className: "size-4" }),
|
|
27
|
+
/* @__PURE__ */ jsx("span", {
|
|
28
|
+
className: "flex-1",
|
|
29
|
+
children: "Theme"
|
|
30
|
+
}),
|
|
31
|
+
/* @__PURE__ */ jsx(Segmented, {
|
|
32
|
+
label: "Theme",
|
|
33
|
+
iconOnly: true,
|
|
34
|
+
value: theme.preference,
|
|
35
|
+
options,
|
|
36
|
+
onChange: theme.onChange
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
//#endregion
|
|
41
|
+
export { ThemeSwitch };
|
package/dist/auth/auth-gate.js
CHANGED
|
@@ -69,15 +69,6 @@ const AuthGate = ({ workspace, children }) => {
|
|
|
69
69
|
useEffect(() => {
|
|
70
70
|
if (state.kind === "signed-out" && page.kind === "sign-in" && page.providerError) forgetSignInLink();
|
|
71
71
|
}, [state.kind, page]);
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
if (state.kind !== "signed-out" || !browse) return;
|
|
74
|
-
const leave = () => {
|
|
75
|
-
setPage({ kind: "sign-in" });
|
|
76
|
-
browse();
|
|
77
|
-
};
|
|
78
|
-
window.addEventListener("popstate", leave);
|
|
79
|
-
return () => window.removeEventListener("popstate", leave);
|
|
80
|
-
}, [state.kind, browse]);
|
|
81
72
|
const signInFailed = useCallback((error) => setPage({
|
|
82
73
|
kind: "sign-in",
|
|
83
74
|
error
|
|
@@ -203,7 +203,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
203
203
|
children: [header, body]
|
|
204
204
|
}) : /* @__PURE__ */ jsxs(Fragment$1, { children: [header, body] }), horizontal.more && /* @__PURE__ */ jsx("div", {
|
|
205
205
|
"aria-hidden": true,
|
|
206
|
-
className: "pointer-events-none absolute inset-y-0 right-0 z-20 w-8 bg-gradient-to-l from-
|
|
206
|
+
className: "pointer-events-none absolute inset-y-0 right-0 z-20 w-8 bg-gradient-to-l from-backdrop/15 to-transparent"
|
|
207
207
|
})]
|
|
208
208
|
}), !fit && /* @__PURE__ */ jsx(GridFooter, {
|
|
209
209
|
total,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { formatMoney } from "../format/money.js";
|
|
2
1
|
import { keys } from "../data/query-keys.js";
|
|
3
2
|
import { useClient } from "../data/api-provider.js";
|
|
4
3
|
import { labelFieldOf } from "./model-helpers.js";
|
|
@@ -8,7 +7,7 @@ import { setRange } from "../filter-panel/filter-state.js";
|
|
|
8
7
|
import { stateToText } from "./filter-string.js";
|
|
9
8
|
import { labelKeys, matchOptions } from "./reference-options.js";
|
|
10
9
|
import { useReferenceSearch } from "./use-reference-search.js";
|
|
11
|
-
import { rangeHistogram, widgetFields } from "./view-fields.js";
|
|
10
|
+
import { rangeFormat, rangeHistogram, widgetFields } from "./view-fields.js";
|
|
12
11
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
13
12
|
import { enumLabel } from "@protobase/schema";
|
|
14
13
|
//#region src/live/use-filter-config.ts
|
|
@@ -107,9 +106,6 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
107
106
|
const rangeConfig = (range, index) => {
|
|
108
107
|
const data = histogramQueries[index]?.data;
|
|
109
108
|
if (!data || data.min === null || data.max === null) return [];
|
|
110
|
-
const hints = view?.fields[range.id];
|
|
111
|
-
const prefix = hints?.prefix;
|
|
112
|
-
const suffix = hints?.suffix ?? "";
|
|
113
109
|
return [{
|
|
114
110
|
...range,
|
|
115
111
|
label: label(range.id),
|
|
@@ -117,7 +113,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
117
113
|
max: Math.ceil(data.max),
|
|
118
114
|
step: Math.max(1, Math.round((data.max - data.min) / 100)),
|
|
119
115
|
histogram: rangeHistogram(view, range.id) ? data.buckets.map((bucket) => bucket.count) : [],
|
|
120
|
-
format: (
|
|
116
|
+
format: rangeFormat(view, range.id)
|
|
121
117
|
}];
|
|
122
118
|
};
|
|
123
119
|
return {
|
package/dist/live/view-fields.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatMoney } from "../format/money.js";
|
|
1
2
|
//#region src/live/view-fields.ts
|
|
2
3
|
const numberTypes = [
|
|
3
4
|
"integer",
|
|
@@ -61,5 +62,11 @@ const widgetFields = (view, model) => {
|
|
|
61
62
|
};
|
|
62
63
|
/** Whether a range draws its histogram: only a `range` widget that asks for one. */
|
|
63
64
|
const rangeHistogram = (view, field) => (view?.filters ?? []).some((widget) => widget.kind === "range" && widget.field === field && widget.histogram);
|
|
65
|
+
/** How a range's values read, by the view's prefix and suffix: "€450,000" for a euro amount, "85 m²". */
|
|
66
|
+
const rangeFormat = (view, field) => {
|
|
67
|
+
const hints = view?.fields[field];
|
|
68
|
+
const suffix = hints?.suffix ?? "";
|
|
69
|
+
return (value) => `${hints?.prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)}${suffix}`;
|
|
70
|
+
};
|
|
64
71
|
//#endregion
|
|
65
|
-
export { rangeHistogram, widgetFields };
|
|
72
|
+
export { rangeFormat, rangeHistogram, widgetFields };
|
package/dist/package.js
CHANGED
|
@@ -7,7 +7,7 @@ const Dialog = ({ title, description, onClose, actions, children }) => {
|
|
|
7
7
|
const panel = useRef(null);
|
|
8
8
|
useFocusTrap(panel, true, onClose);
|
|
9
9
|
return /* @__PURE__ */ jsx("div", {
|
|
10
|
-
className: "fixed inset-0 z-[90] flex items-center justify-center bg-
|
|
10
|
+
className: "fixed inset-0 z-[90] flex items-center justify-center bg-backdrop/30 p-4",
|
|
11
11
|
onMouseDown: (event) => event.target === event.currentTarget && onClose(),
|
|
12
12
|
children: /* @__PURE__ */ jsxs("div", {
|
|
13
13
|
ref: panel,
|
|
@@ -11,8 +11,7 @@ export type ResponsiveDialogProps = {
|
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
13
|
* A modal centred on the screen from the `md` breakpoint up, and a sheet rising from the bottom on a phone, switching as
|
|
14
|
-
* the viewport does. Both close on Escape and on a press outside, and keep focus inside while open
|
|
15
|
-
* closes when swiped down.
|
|
14
|
+
* the viewport does. Both close on Escape and on a press outside, and keep focus inside while open.
|
|
16
15
|
*/
|
|
17
16
|
export declare const ResponsiveDialog: ({ title, description, open, onClose, actions, children }: ResponsiveDialogProps) => import("react").JSX.Element | null;
|
|
18
17
|
//#endregion
|
|
@@ -5,23 +5,21 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
//#region src/primitives/responsive-dialog.tsx
|
|
6
6
|
/**
|
|
7
7
|
* A modal centred on the screen from the `md` breakpoint up, and a sheet rising from the bottom on a phone, switching as
|
|
8
|
-
* the viewport does. Both close on Escape and on a press outside, and keep focus inside while open
|
|
9
|
-
* closes when swiped down.
|
|
8
|
+
* the viewport does. Both close on Escape and on a press outside, and keep focus inside while open.
|
|
10
9
|
*/
|
|
11
10
|
const ResponsiveDialog = ({ title, description, open, onClose, actions, children }) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
11
|
+
const desktop = useIsDesktop();
|
|
12
|
+
if (!open) return null;
|
|
13
|
+
if (desktop) return /* @__PURE__ */ jsx(Dialog, {
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
onClose,
|
|
17
|
+
actions,
|
|
18
|
+
children
|
|
19
|
+
});
|
|
22
20
|
return /* @__PURE__ */ jsx(Sheet, {
|
|
23
21
|
title,
|
|
24
|
-
open,
|
|
22
|
+
open: true,
|
|
25
23
|
onClose,
|
|
26
24
|
children: /* @__PURE__ */ jsxs("div", {
|
|
27
25
|
className: "pt-3 text-[13px]",
|
|
@@ -11,16 +11,20 @@ const sizes = {
|
|
|
11
11
|
option: "px-3 text-[13px]"
|
|
12
12
|
}
|
|
13
13
|
};
|
|
14
|
-
const Segmented = ({ value, options, onChange, label, size = "sm" }) => /* @__PURE__ */ jsx("span", {
|
|
14
|
+
const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = false }) => /* @__PURE__ */ jsx("span", {
|
|
15
15
|
role: "group",
|
|
16
16
|
"aria-label": label,
|
|
17
17
|
className: cn("inline-flex", sizes[size].group, "rounded-md bg-muted p-0.5"),
|
|
18
18
|
children: options.map((option) => /* @__PURE__ */ jsxs("button", {
|
|
19
19
|
type: "button",
|
|
20
20
|
"aria-pressed": option.value === value,
|
|
21
|
+
...iconOnly && {
|
|
22
|
+
"aria-label": option.label,
|
|
23
|
+
title: option.label
|
|
24
|
+
},
|
|
21
25
|
onClick: () => onChange(option.value),
|
|
22
26
|
className: cn("inline-flex items-center gap-1.5 rounded", sizes[size].option, "font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
|
|
23
|
-
children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), option.label]
|
|
27
|
+
children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), !iconOnly && option.label]
|
|
24
28
|
}, option.value))
|
|
25
29
|
});
|
|
26
30
|
//#endregion
|