@protobase/ui 0.15.1 → 0.15.3

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.
@@ -1,4 +1,4 @@
1
- import { Bed, Bell, Bookmark, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bed, Bell, Bookmark, ExternalLink, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -9,7 +9,8 @@ const byName = {
9
9
  bed: Bed,
10
10
  heart: Heart,
11
11
  bell: Bell,
12
- bookmark: Bookmark
12
+ bookmark: Bookmark,
13
+ "external-link": ExternalLink
13
14
  };
14
15
  /** The icon a name stands for, or nothing for a name the app does not know. */
15
16
  const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
@@ -1,5 +1,43 @@
1
+ import { hrefField } from "@protobase/schema/internal";
1
2
  //#region src/app/pages/action-href.ts
2
3
  /** `href` with every `{field}` replaced by the record's value, percent-encoded; a missing value becomes empty. */
3
4
  const fillHref = (href, record = {}) => href.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => encodeURIComponent(record[name] === null || record[name] === void 0 ? "" : String(record[name])));
5
+ const webUrl = (value) => {
6
+ if (typeof value !== "string" || !URL.canParse(value)) return void 0;
7
+ const url = new URL(value);
8
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : void 0;
9
+ };
10
+ /**
11
+ * Where a link action's `href` goes from `record`: an href that is only `{field}` is the record's value as it is, when
12
+ * that is an `https:` or `http:` URL, and nothing otherwise; any other href is filled by `fillHref`.
13
+ */
14
+ const linkTarget = (href, record = {}) => {
15
+ const field = hrefField(href);
16
+ return field === void 0 ? fillHref(href, record) : webUrl(record[field]);
17
+ };
18
+ /**
19
+ * Opens a link's target: an `https:` or `http:` page in a new tab that gets neither the app's window nor its address,
20
+ * anything else (`mailto:`) in place. A link without a target does nothing.
21
+ */
22
+ const openLink = (target) => {
23
+ if (target === void 0) return;
24
+ if (/^https?:/i.test(target)) window.open(target, "_blank", "noopener,noreferrer");
25
+ else window.location.assign(target);
26
+ };
27
+ const hostOf = (target) => URL.canParse(target) ? new URL(target).hostname.replace(/^www\./, "") : "";
28
+ /**
29
+ * `action` as it shows for `record`: a link whose `href` has no target there is left out, and `{host}` in a link's label
30
+ * becomes the host its target opens, without `www.`.
31
+ */
32
+ const actionFor = (action, record) => {
33
+ const run = action.run;
34
+ if (run?.kind !== "link" || !("href" in run)) return action;
35
+ const target = linkTarget(run.href, record);
36
+ if (target === void 0) return void 0;
37
+ return action.label.includes("{host}") ? {
38
+ ...action,
39
+ label: action.label.replaceAll("{host}", hostOf(target))
40
+ } : action;
41
+ };
4
42
  //#endregion
5
- export { fillHref };
43
+ export { actionFor, fillHref, linkTarget, openLink };
@@ -5,15 +5,16 @@ import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
5
  import { useAppAddress } from "../use-app-address.js";
6
6
  import { can } from "../permissions.js";
7
7
  import { useRefreshResource } from "./use-layout-data.js";
8
- import { fillHref } from "./action-href.js";
8
+ import { actionFor, linkTarget, openLink } from "./action-href.js";
9
9
  import { useState } from "react";
10
10
  import { fillAddress } from "@protobase/schema/internal";
11
11
  //#region src/app/pages/use-run-action.ts
12
12
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
13
  /**
14
14
  * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
15
- * (`update`, `delete`, `link`), else the handler the project registered under the action's name. `blocked` says why one
16
- * cannot run; `afterRun` hears about every action that succeeded.
15
+ * (`update`, `delete`, `link`), else the handler the project registered under the action's name. A link to a URL the
16
+ * record holds is left out while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action
17
+ * that succeeded.
17
18
  */
18
19
  const useActionRunner = (resource, scope, afterRun) => {
19
20
  const { views } = useAdminMeta();
@@ -25,7 +26,7 @@ const useActionRunner = (resource, scope, afterRun) => {
25
26
  const refresh = useRefreshResource();
26
27
  const { go } = useAppAddress();
27
28
  const [running, setRunning] = useState();
28
- const actions = views[resource]?.actions ?? [];
29
+ const actions = (views[resource]?.actions ?? []).flatMap((action) => actionFor(action, scope?.record) ?? []);
29
30
  const blocked = (action) => {
30
31
  const kind = action.run?.kind;
31
32
  if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
@@ -37,7 +38,7 @@ const useActionRunner = (resource, scope, afterRun) => {
37
38
  const { run } = action;
38
39
  if (run?.kind === "link") {
39
40
  if ("to" in run) go(fillAddress(run.to, scope?.record));
40
- else window.location.assign(fillHref(run.href, scope?.record));
41
+ else openLink(linkTarget(run.href, scope?.record));
41
42
  return;
42
43
  }
43
44
  if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
@@ -31,6 +31,10 @@ export type MetaData = {
31
31
  maps?: {
32
32
  style: string;
33
33
  };
34
+ /** The hosts besides the app's own whose images are drawn with the page's referrer (see `FileImage`). */
35
+ files?: {
36
+ trustedHosts: string[];
37
+ };
34
38
  /** The plugins the app loads for the user (see `PluginGate`). */
35
39
  plugins?: PluginRef[];
36
40
  /** The other APIs the app draws resources from for the user, and how far their `/meta` has loaded (see `withSources`). */
@@ -37,6 +37,7 @@ const useMeta = () => {
37
37
  ...result.meta.runtime && { runtime: result.meta.runtime },
38
38
  ...result.meta.errorReporting && { errorReporting: result.meta.errorReporting },
39
39
  ...result.meta.maps && { maps: result.meta.maps },
40
+ ...result.meta.files && { files: result.meta.files },
40
41
  ...result.meta.plugins && { plugins: result.meta.plugins },
41
42
  ...result.meta.sources && { sources: result.meta.sources.map((source) => ({
42
43
  source,
@@ -1,3 +1,5 @@
1
+ import { imageReferrerPolicy } from "./image-referrer.js";
2
+ import { useTrustedImageHosts } from "./use-trusted-image-hosts.js";
1
3
  import { useMemo, useState } from "react";
2
4
  import { jsx } from "react/jsx-runtime";
3
5
  import { thumbHashToDataURL } from "thumbhash";
@@ -12,9 +14,13 @@ const placeholderImage = (hash) => {
12
14
  }
13
15
  return thumbHashToDataURL(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
14
16
  };
15
- /** An image of a file, its placeholder drawn blurred behind it until it has loaded. */
17
+ /**
18
+ * An image of a file, its placeholder drawn blurred behind it until it has loaded. One from another site than the app's
19
+ * own and its trusted hosts loads without a referrer.
20
+ */
16
21
  const FileImage = ({ image, className, loading }) => {
17
22
  const [loaded, setLoaded] = useState();
23
+ const trustedHosts = useTrustedImageHosts();
18
24
  const placeholder = useMemo(() => image.placeholder === void 0 ? void 0 : placeholderImage(image.placeholder), [image.placeholder]);
19
25
  const waiting = placeholder !== void 0 && loaded !== image.url;
20
26
  return /* @__PURE__ */ jsx("img", {
@@ -22,6 +28,7 @@ const FileImage = ({ image, className, loading }) => {
22
28
  alt: "",
23
29
  loading,
24
30
  decoding: "async",
31
+ referrerPolicy: imageReferrerPolicy(image.url, window.location.href, trustedHosts),
25
32
  onLoad: () => setLoaded(image.url),
26
33
  className,
27
34
  style: waiting ? {
@@ -0,0 +1,15 @@
1
+ //#region src/live/image-referrer.ts
2
+ /**
3
+ * The `referrerPolicy` an image at `url` is drawn with on the page at `page`: the browser's default for the page's own
4
+ * origin, a trusted host, or a local image (`data:`, `blob:`); `no-referrer` for any other, so another site never learns
5
+ * which page of the app showed its image.
6
+ */
7
+ const imageReferrerPolicy = (url, page, trustedHosts) => {
8
+ if (!URL.canParse(url, page)) return "no-referrer";
9
+ const target = new URL(url, page);
10
+ if (target.protocol !== "http:" && target.protocol !== "https:") return void 0;
11
+ if (target.origin === new URL(page).origin || trustedHosts.includes(target.hostname)) return void 0;
12
+ return "no-referrer";
13
+ };
14
+ //#endregion
15
+ export { imageReferrerPolicy };
@@ -0,0 +1,14 @@
1
+ import { MetaContext } from "../data/meta-context.js";
2
+ import { useContext, useMemo } from "react";
3
+ //#region src/live/use-trusted-image-hosts.ts
4
+ const hostOf = (url) => URL.canParse(url) ? [new URL(url).hostname] : [];
5
+ /**
6
+ * The hosts besides the page's own whose images are drawn with the page's referrer: those `/meta` names, where the app's
7
+ * providers link public files from, and those of the app's sources. None outside `MetaGate`.
8
+ */
9
+ const useTrustedImageHosts = () => {
10
+ const meta = useContext(MetaContext);
11
+ return useMemo(() => [...meta?.files?.trustedHosts ?? [], ...(meta?.sources ?? []).flatMap(({ source }) => hostOf(source.url))], [meta?.files, meta?.sources]);
12
+ };
13
+ //#endregion
14
+ export { useTrustedImageHosts };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.15.1";
2
+ var version = "0.15.3";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };