@yuneta/gobj-ui 7.23.45 → 7.23.47

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/README.md CHANGED
@@ -1364,6 +1364,59 @@ only while there is somewhere to go back to, spent when used, and dropped by a
1364
1364
  refresh. Undoing is another write, like the drag. The host must define the key
1365
1365
  `"undo the order"`.
1366
1366
 
1367
+ ### The bytes a node owns but cannot hold — `yui_asset_*`
1368
+
1369
+ A treedb node often owns something that is not JSON: a photo, a plan, a
1370
+ clip. Those bytes cannot live in the treedb — it is held in memory and
1371
+ timeranger2 rewrites the whole record on every update — so the SDK's
1372
+ `C_ASSETS` keeps them in a directory it owns and the node names one with an
1373
+ **fkey**.
1374
+
1375
+ `get-asset` answers in one of two shapes, and the **backend** decides which:
1376
+
1377
+ ```json
1378
+ {"mode": "url", "url": "/assets/ab/cd/<id>.jpg?e=<expires>&s=<token>"}
1379
+ {"mode": "inline", "content_type": "image/jpeg", "content64": "..."}
1380
+ ```
1381
+
1382
+ It signs a URL when a web server sits in front of the store and hands over
1383
+ the bytes when there is none, so a consumer has **one** code path and a node
1384
+ with no web server still shows its images instead of showing nothing.
1385
+
1386
+ This module is the two ends of that, and **it does not talk to the
1387
+ backend** — asking is an action and belongs in the view's own FSM:
1388
+
1389
+ | | |
1390
+ |---|---|
1391
+ | `yui_asset_id(ref)` / `yui_asset_ids(ref)` | the id(s) a column names. A link comes back in whichever shape the READER asked for — the stored `"assets^<id>^as_foto"`, the bare `"<id>"` that `fkey_only_id` collapses it to, or an expanded `{id}` — and either alone or in a list, because an unset single-valued fkey is still an empty list. All of them are read; an empty column answers nothing rather than throwing |
1392
+ | `yui_asset_src(answer)` | the two shapes into one `src`. `null` when the answer carries neither — never an empty string, because `<img src="">` reloads the page in some browsers |
1393
+ | `yui_asset_element(answer, opts)` | the element, picked from the **content type the backend stored**: `<img>`, `<video>` or `<audio>`. Video and audio are assets too, and an `<img>` whose src is a film shows the broken box this exists to remove |
1394
+ | `yui_asset_missing(detail, opts)` | the marker, for when there is nothing to show |
1395
+
1396
+ **A missing asset is now said out loud.** It used to leave a broken box and
1397
+ no word about it, which is indistinguishable from a slow one and from a bug
1398
+ — 47 such holes in one day on `artgins.ytreedb.com` before anybody noticed.
1399
+ `yui_asset_element()` wires `onerror` so the dead element is **replaced** by
1400
+ the marker, whatever the reason: an expired signature, a blob gone from the
1401
+ store, an unsupported codec.
1402
+
1403
+ The marker's label carries its i18n key (`asset not available`, overridable
1404
+ with `opts.key`), so it follows a language change; `opts.detail` is DATA — a
1405
+ name or a path, the thing a person can act on — and is never translated.
1406
+
1407
+ ```js
1408
+ import {yui_asset_id, yui_asset_element} from "@yuneta/gobj-ui/src/yui_asset.js";
1409
+ import "@yuneta/gobj-ui/src/yui_asset.css";
1410
+
1411
+ // in the view's FSM, not in a DOM callback:
1412
+ const id = yui_asset_id(device.foto);
1413
+ if(id) {
1414
+ gobj_send_event(gobj, "EV_ASK_ASSET", {asset_id: id, slot: "foto"}, gobj);
1415
+ }
1416
+ // ...and when the answer arrives, in the action:
1417
+ $box.appendChild(yui_asset_element(answer, {detail: device.foto_name}));
1418
+ ```
1419
+
1367
1420
  ### Frontend view — `setup_frontend_view`
1368
1421
 
1369
1422
  `setup_frontend_view(self)` opens the **gobj tree of the app's own yuno** in a
@@ -67043,6 +67043,119 @@ function register_c_yui_period() {
67043
67043
  return create_gclass(GCLASS_NAME);
67044
67044
  }
67045
67045
  //#endregion
67046
+ //#region src/yui_asset.js
67047
+ function ref_to_id(ref) {
67048
+ if (!ref) return null;
67049
+ if (typeof ref === "string") {
67050
+ if (ref.indexOf("^") < 0) return ref;
67051
+ const parts = ref.split("^");
67052
+ return parts.length >= 2 && parts[1] ? parts[1] : null;
67053
+ }
67054
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
67055
+ return null;
67056
+ }
67057
+ /***************************************************************************
67058
+ * Every asset id a column names, in order. Always an array, possibly empty.
67059
+ ***************************************************************************/
67060
+ function yui_asset_ids(ref) {
67061
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
67062
+ const id = ref_to_id(ref);
67063
+ return id ? [id] : [];
67064
+ }
67065
+ /***************************************************************************
67066
+ * The first asset id a column names, or null.
67067
+ ***************************************************************************/
67068
+ function yui_asset_id(ref) {
67069
+ const ids = yui_asset_ids(ref);
67070
+ return ids.length ? ids[0] : null;
67071
+ }
67072
+ /***************************************************************************
67073
+ * Turn a `get-asset` answer into something an element can load.
67074
+ *
67075
+ * Returns null when the answer carries neither -- which the caller must
67076
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
67077
+ * PAGE in some browsers.
67078
+ ***************************************************************************/
67079
+ function yui_asset_src(answer) {
67080
+ if (!answer) return null;
67081
+ if (answer.mode === "url" && answer.url) return answer.url;
67082
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
67083
+ return null;
67084
+ }
67085
+ /***************************************************************************
67086
+ * The marker for an asset that is not there.
67087
+ *
67088
+ * A missing image used to leave a broken box and no word about it, which
67089
+ * is indistinguishable from a slow one and from a bug. This says so, and
67090
+ * it says WHICH: `detail` is data (a name, a path) and is never
67091
+ * translated, while the label carries its i18n key so a language change
67092
+ * re-translates it.
67093
+ ***************************************************************************/
67094
+ function yui_asset_missing(detail, opts = {}) {
67095
+ const key = opts.key || "asset not available";
67096
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
67097
+ "span",
67098
+ {
67099
+ class: "ASSET_MISSING_LABEL",
67100
+ i18n: key
67101
+ },
67102
+ key
67103
+ ]];
67104
+ if (detail) children.push([
67105
+ "span",
67106
+ { class: "ASSET_MISSING_DETAIL" },
67107
+ String(detail)
67108
+ ]);
67109
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
67110
+ if (detail) attrs.title = String(detail);
67111
+ else attrs["data-i18n-title"] = key;
67112
+ return (0, _yuneta_gobj_js.createElement2)([
67113
+ "div",
67114
+ attrs,
67115
+ children
67116
+ ]);
67117
+ }
67118
+ /***************************************************************************
67119
+ * The element for one asset.
67120
+ *
67121
+ * The KIND comes from the content type the backend stored, not from the
67122
+ * name a person typed: video and audio are assets too, and an <img> whose
67123
+ * src is a film shows the broken box this module exists to remove.
67124
+ *
67125
+ * `detail` is what the missing marker shows -- pass the original name or
67126
+ * the source path, the thing a person can act on.
67127
+ ***************************************************************************/
67128
+ function yui_asset_element(answer, opts = {}) {
67129
+ const src = yui_asset_src(answer);
67130
+ if (!src) return yui_asset_missing(opts.detail, opts);
67131
+ const ct = (answer.content_type || "").toLowerCase();
67132
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
67133
+ let el;
67134
+ if (ct.startsWith("video/")) el = (0, _yuneta_gobj_js.createElement2)(["video", {
67135
+ class: cls,
67136
+ controls: "",
67137
+ preload: "metadata"
67138
+ }]);
67139
+ else if (ct.startsWith("audio/")) el = (0, _yuneta_gobj_js.createElement2)(["audio", {
67140
+ class: cls,
67141
+ controls: "",
67142
+ preload: "metadata"
67143
+ }]);
67144
+ else {
67145
+ el = (0, _yuneta_gobj_js.createElement2)(["img", {
67146
+ class: cls,
67147
+ loading: "lazy"
67148
+ }]);
67149
+ el.alt = opts.alt || opts.detail || "";
67150
+ }
67151
+ el.src = src;
67152
+ el.onerror = () => {
67153
+ const $marker = yui_asset_missing(opts.detail, opts);
67154
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
67155
+ };
67156
+ return el;
67157
+ }
67158
+ //#endregion
67046
67159
  //#region src/dev_machine_trace.js
67047
67160
  /***********************************************************************
67048
67161
  * dev_machine_trace.js
@@ -68791,6 +68904,11 @@ exports.set_submit_state = set_submit_state;
68791
68904
  exports.setup_dev = setup_dev;
68792
68905
  exports.setup_frontend_view = setup_frontend_view;
68793
68906
  exports.toggleClasses = toggleClasses;
68907
+ exports.yui_asset_element = yui_asset_element;
68908
+ exports.yui_asset_id = yui_asset_id;
68909
+ exports.yui_asset_ids = yui_asset_ids;
68910
+ exports.yui_asset_missing = yui_asset_missing;
68911
+ exports.yui_asset_src = yui_asset_src;
68794
68912
  exports.yui_is_dark = yui_is_dark;
68795
68913
  exports.yui_mount_service_view = yui_mount_service_view;
68796
68914
  exports.yui_shell_close_drawer = yui_shell_close_drawer;
@@ -67038,6 +67038,119 @@ function register_c_yui_period() {
67038
67038
  return create_gclass(GCLASS_NAME);
67039
67039
  }
67040
67040
  //#endregion
67041
+ //#region src/yui_asset.js
67042
+ function ref_to_id(ref) {
67043
+ if (!ref) return null;
67044
+ if (typeof ref === "string") {
67045
+ if (ref.indexOf("^") < 0) return ref;
67046
+ const parts = ref.split("^");
67047
+ return parts.length >= 2 && parts[1] ? parts[1] : null;
67048
+ }
67049
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
67050
+ return null;
67051
+ }
67052
+ /***************************************************************************
67053
+ * Every asset id a column names, in order. Always an array, possibly empty.
67054
+ ***************************************************************************/
67055
+ function yui_asset_ids(ref) {
67056
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
67057
+ const id = ref_to_id(ref);
67058
+ return id ? [id] : [];
67059
+ }
67060
+ /***************************************************************************
67061
+ * The first asset id a column names, or null.
67062
+ ***************************************************************************/
67063
+ function yui_asset_id(ref) {
67064
+ const ids = yui_asset_ids(ref);
67065
+ return ids.length ? ids[0] : null;
67066
+ }
67067
+ /***************************************************************************
67068
+ * Turn a `get-asset` answer into something an element can load.
67069
+ *
67070
+ * Returns null when the answer carries neither -- which the caller must
67071
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
67072
+ * PAGE in some browsers.
67073
+ ***************************************************************************/
67074
+ function yui_asset_src(answer) {
67075
+ if (!answer) return null;
67076
+ if (answer.mode === "url" && answer.url) return answer.url;
67077
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
67078
+ return null;
67079
+ }
67080
+ /***************************************************************************
67081
+ * The marker for an asset that is not there.
67082
+ *
67083
+ * A missing image used to leave a broken box and no word about it, which
67084
+ * is indistinguishable from a slow one and from a bug. This says so, and
67085
+ * it says WHICH: `detail` is data (a name, a path) and is never
67086
+ * translated, while the label carries its i18n key so a language change
67087
+ * re-translates it.
67088
+ ***************************************************************************/
67089
+ function yui_asset_missing(detail, opts = {}) {
67090
+ const key = opts.key || "asset not available";
67091
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
67092
+ "span",
67093
+ {
67094
+ class: "ASSET_MISSING_LABEL",
67095
+ i18n: key
67096
+ },
67097
+ key
67098
+ ]];
67099
+ if (detail) children.push([
67100
+ "span",
67101
+ { class: "ASSET_MISSING_DETAIL" },
67102
+ String(detail)
67103
+ ]);
67104
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
67105
+ if (detail) attrs.title = String(detail);
67106
+ else attrs["data-i18n-title"] = key;
67107
+ return createElement2([
67108
+ "div",
67109
+ attrs,
67110
+ children
67111
+ ]);
67112
+ }
67113
+ /***************************************************************************
67114
+ * The element for one asset.
67115
+ *
67116
+ * The KIND comes from the content type the backend stored, not from the
67117
+ * name a person typed: video and audio are assets too, and an <img> whose
67118
+ * src is a film shows the broken box this module exists to remove.
67119
+ *
67120
+ * `detail` is what the missing marker shows -- pass the original name or
67121
+ * the source path, the thing a person can act on.
67122
+ ***************************************************************************/
67123
+ function yui_asset_element(answer, opts = {}) {
67124
+ const src = yui_asset_src(answer);
67125
+ if (!src) return yui_asset_missing(opts.detail, opts);
67126
+ const ct = (answer.content_type || "").toLowerCase();
67127
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
67128
+ let el;
67129
+ if (ct.startsWith("video/")) el = createElement2(["video", {
67130
+ class: cls,
67131
+ controls: "",
67132
+ preload: "metadata"
67133
+ }]);
67134
+ else if (ct.startsWith("audio/")) el = createElement2(["audio", {
67135
+ class: cls,
67136
+ controls: "",
67137
+ preload: "metadata"
67138
+ }]);
67139
+ else {
67140
+ el = createElement2(["img", {
67141
+ class: cls,
67142
+ loading: "lazy"
67143
+ }]);
67144
+ el.alt = opts.alt || opts.detail || "";
67145
+ }
67146
+ el.src = src;
67147
+ el.onerror = () => {
67148
+ const $marker = yui_asset_missing(opts.detail, opts);
67149
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
67150
+ };
67151
+ return el;
67152
+ }
67153
+ //#endregion
67041
67154
  //#region src/dev_machine_trace.js
67042
67155
  /***********************************************************************
67043
67156
  * dev_machine_trace.js
@@ -68721,6 +68834,6 @@ function setup_frontend_view(self) {
68721
68834
  return win;
68722
68835
  }
68723
68836
  //#endregion
68724
- export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_fsm_graph, register_c_yui_gclass, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_schema_editor, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_pressed_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_danger, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_badge, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
68837
+ export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_fsm_graph, register_c_yui_gclass, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_schema_editor, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_pressed_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_asset_element, yui_asset_id, yui_asset_ids, yui_asset_missing, yui_asset_src, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_danger, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_badge, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
68725
68838
 
68726
68839
  //# sourceMappingURL=gobj-ui.es.js.map
package/index.js CHANGED
@@ -119,6 +119,7 @@ export {
119
119
  export { addClasses, removeClasses, toggleClasses, removeChildElements, disableElements, enableElements, set_submit_state, set_cancel_state, set_active_state, set_pressed_state, getStrokeColor } from "./src/lib_graph.js";
120
120
  export { inject_svg_icons } from "./src/lib_icons.js";
121
121
  export { EditControl, MarkerControl } from "./src/lib_maplibre.js";
122
+ export { yui_asset_id, yui_asset_ids, yui_asset_src, yui_asset_element, yui_asset_missing } from "./src/yui_asset.js";
122
123
  export { yui_toolbar } from "./src/yui_toolbar.js";
123
124
  export { yui_theme_now, yui_is_dark, yui_watch_theme } from "./src/yui_theme.js";
124
125
  export { attach_clear, refresh_clear } from "./src/yui_inputs.js";
@@ -131,6 +132,7 @@ export { setup_frontend_view } from "./src/yui_frontend_view.js";
131
132
  * import "gobj-ui/src/c_yui_shell.css"; // declarative shell
132
133
  * import "gobj-ui/src/c_yui_map.css";
133
134
  * import "gobj-ui/src/yui_toolbar.css";
135
+ * import "gobj-ui/src/yui_asset.css";
134
136
  * import "gobj-ui/src/lib_graph.css";
135
137
  * import "gobj-ui/src/yui_icons.css";
136
138
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.45",
3
+ "version": "7.23.47",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -20,7 +20,7 @@
20
20
  "src/",
21
21
  "vite-plugin-yuneta-html.js"
22
22
  ],
23
- "description": "Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
23
+ "description": "Yuneta UI Library \u2014 the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
24
24
  "scripts": {
25
25
  "build": "vite build",
26
26
  "test": "vitest run",
@@ -0,0 +1,43 @@
1
+ /*
2
+ * The marker of an asset that is not there.
3
+ *
4
+ * It takes the SPACE an image would have taken, so a card does not jump
5
+ * when one is missing, and it is quiet enough to sit in a gallery without
6
+ * shouting -- it reports a fact, it is not an error dialog.
7
+ */
8
+ .ASSET_MISSING {
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ justify-content: center;
13
+ gap: 0.25rem;
14
+ min-width: 5rem;
15
+ min-height: 5rem;
16
+ padding: 0.5rem;
17
+ border: 1px dashed var(--bulma-border, #dbdbdb);
18
+ border-radius: var(--bulma-radius, 4px);
19
+ color: var(--bulma-text-weak, #7a7a7a);
20
+ background: var(--bulma-scheme-main-bis, transparent);
21
+ text-align: center;
22
+ }
23
+
24
+ .ASSET_MISSING_ICON {
25
+ font-size: 1.25rem;
26
+ opacity: 0.7;
27
+ }
28
+
29
+ .ASSET_MISSING_LABEL {
30
+ font-size: 0.75rem;
31
+ }
32
+
33
+ /*
34
+ * The detail is a name or a path: it must not stretch the card, and it is
35
+ * the one thing a person can act on, so it stays readable rather than
36
+ * being cut to nothing.
37
+ */
38
+ .ASSET_MISSING_DETAIL {
39
+ font-size: 0.7rem;
40
+ opacity: 0.75;
41
+ max-width: 12rem;
42
+ overflow-wrap: anywhere;
43
+ }
@@ -0,0 +1,195 @@
1
+ /*
2
+ * The bytes a treedb node owns but cannot hold -- a photo, a plan, a clip.
3
+ *
4
+ * A node names its asset with an FKEY, and the BACKEND decides how the
5
+ * bytes travel: a signed url when a web server sits in front of the store,
6
+ * the bytes inline when there is none (the SDK's `C_ASSETS get-asset`).
7
+ * That is why a consumer has ONE code path, and why a node with no web
8
+ * server in front of it still shows its images instead of showing nothing.
9
+ *
10
+ * WHAT THIS MODULE DOES NOT DO IS TALK TO THE BACKEND. Asking is an
11
+ * ACTION and belongs in the view's own FSM; these are the two ends of it:
12
+ * read the id out of the fkey before asking, and turn the answer into an
13
+ * element afterwards.
14
+ *
15
+ * Using Bulma Framework (https://bulma.io)
16
+ */
17
+
18
+ import {
19
+ createElement2
20
+ } from "@yuneta/gobj-js";
21
+
22
+ import "./yui_asset.css";
23
+
24
+ /*
25
+ * A column that holds a link comes back in one of THREE shapes, and which
26
+ * one is the READER's choice, not the schema's:
27
+ *
28
+ * "assets^<id>^as_foto" the stored fkey, as it is persisted;
29
+ * "<id>" what `fkey_only_id` collapses it to, which is
30
+ * what a caller asks for when it wants clean ids;
31
+ * {id: "<id>", ...} an expanded ref, from the `refs` options.
32
+ *
33
+ * And a column holds either ONE of them (a single-valued fkey, like a
34
+ * device's photo) or a LIST (an array fkey, like the plans of a bay) --
35
+ * and an UNSET single-valued fkey still comes back as an empty list.
36
+ *
37
+ * Reading only the first shape was a real bug: against a backend that
38
+ * reads with `fkey_only_id` every link looked like no link at all. The
39
+ * `^` is what tells the two string shapes apart, and an id can never carry
40
+ * one -- that is why the qualified key uses `.` and not `^`.
41
+ */
42
+ function ref_to_id(ref)
43
+ {
44
+ if(!ref) {
45
+ return null;
46
+ }
47
+ if(typeof ref === "string") {
48
+ if(ref.indexOf("^") < 0) {
49
+ return ref; // already only the id
50
+ }
51
+ const parts = ref.split("^");
52
+ return parts.length >= 2 && parts[1]? parts[1]: null;
53
+ }
54
+ if(typeof ref === "object" && typeof ref.id === "string") {
55
+ return ref.id;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /***************************************************************************
61
+ * Every asset id a column names, in order. Always an array, possibly empty.
62
+ ***************************************************************************/
63
+ function yui_asset_ids(ref)
64
+ {
65
+ if(Array.isArray(ref)) {
66
+ return ref.map(ref_to_id).filter((id) => !!id);
67
+ }
68
+ const id = ref_to_id(ref);
69
+ return id? [id]: [];
70
+ }
71
+
72
+ /***************************************************************************
73
+ * The first asset id a column names, or null.
74
+ ***************************************************************************/
75
+ function yui_asset_id(ref)
76
+ {
77
+ const ids = yui_asset_ids(ref);
78
+ return ids.length? ids[0]: null;
79
+ }
80
+
81
+ /***************************************************************************
82
+ * Turn a `get-asset` answer into something an element can load.
83
+ *
84
+ * Returns null when the answer carries neither -- which the caller must
85
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
86
+ * PAGE in some browsers.
87
+ ***************************************************************************/
88
+ function yui_asset_src(answer)
89
+ {
90
+ if(!answer) {
91
+ return null;
92
+ }
93
+ if(answer.mode === "url" && answer.url) {
94
+ return answer.url;
95
+ }
96
+ if(answer.mode === "inline" && answer.content64) {
97
+ const ct = answer.content_type || "application/octet-stream";
98
+ return `data:${ct};base64,${answer.content64}`;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /***************************************************************************
104
+ * The marker for an asset that is not there.
105
+ *
106
+ * A missing image used to leave a broken box and no word about it, which
107
+ * is indistinguishable from a slow one and from a bug. This says so, and
108
+ * it says WHICH: `detail` is data (a name, a path) and is never
109
+ * translated, while the label carries its i18n key so a language change
110
+ * re-translates it.
111
+ ***************************************************************************/
112
+ function yui_asset_missing(detail, opts = {})
113
+ {
114
+ const key = opts.key || "asset not available";
115
+ const children = [
116
+ ["span", {class: "ASSET_MISSING_ICON yi-triangle-exclamation"}],
117
+ /*
118
+ * createElement2() translates the content itself when it is given
119
+ * `i18n`, so the KEY is what goes in -- passing t(key) would
120
+ * translate it twice and, worse, freeze it: refresh_language()
121
+ * only re-translates a node that carries its key.
122
+ */
123
+ ["span", {class: "ASSET_MISSING_LABEL", i18n: key}, key]
124
+ ];
125
+ if(detail) {
126
+ children.push(["span", {class: "ASSET_MISSING_DETAIL"}, String(detail)]);
127
+ }
128
+ /*
129
+ * The tooltip is the DETAIL when there is one -- a name or a path,
130
+ * which is data and must never be translated -- and the label
131
+ * otherwise, through `data-i18n-title` so it follows the language.
132
+ */
133
+ const attrs = {class: `ASSET_MISSING ${opts.class || ""}`.trim()};
134
+ if(detail) {
135
+ attrs.title = String(detail);
136
+ } else {
137
+ attrs["data-i18n-title"] = key;
138
+ }
139
+ return createElement2(["div", attrs, children]);
140
+ }
141
+
142
+ /***************************************************************************
143
+ * The element for one asset.
144
+ *
145
+ * The KIND comes from the content type the backend stored, not from the
146
+ * name a person typed: video and audio are assets too, and an <img> whose
147
+ * src is a film shows the broken box this module exists to remove.
148
+ *
149
+ * `detail` is what the missing marker shows -- pass the original name or
150
+ * the source path, the thing a person can act on.
151
+ ***************************************************************************/
152
+ function yui_asset_element(answer, opts = {})
153
+ {
154
+ const src = yui_asset_src(answer);
155
+ if(!src) {
156
+ return yui_asset_missing(opts.detail, opts);
157
+ }
158
+
159
+ const ct = (answer.content_type || "").toLowerCase();
160
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
161
+
162
+ let el;
163
+ if(ct.startsWith("video/")) {
164
+ el = createElement2(["video", {class: cls, controls: "", preload: "metadata"}]);
165
+ } else if(ct.startsWith("audio/")) {
166
+ el = createElement2(["audio", {class: cls, controls: "", preload: "metadata"}]);
167
+ } else {
168
+ el = createElement2(["img", {class: cls, loading: "lazy"}]);
169
+ el.alt = opts.alt || opts.detail || "";
170
+ }
171
+ el.src = src;
172
+
173
+ /*
174
+ * A url can 403 (expired signature), a blob can be gone from the
175
+ * store, a codec can be unsupported. Whatever the reason, the element
176
+ * is REPLACED by the marker: leaving the dead one in place is the
177
+ * silent gap this module was written to stop.
178
+ */
179
+ el.onerror = () => {
180
+ const $marker = yui_asset_missing(opts.detail, opts);
181
+ if(el.parentNode) {
182
+ el.parentNode.replaceChild($marker, el);
183
+ }
184
+ };
185
+
186
+ return el;
187
+ }
188
+
189
+ export {
190
+ yui_asset_id,
191
+ yui_asset_ids,
192
+ yui_asset_src,
193
+ yui_asset_element,
194
+ yui_asset_missing
195
+ };
@@ -0,0 +1,98 @@
1
+ /***********************************************************************
2
+ * yui_asset.test.js
3
+ *
4
+ * Unit tests for the pure logic of yui_asset.
5
+ * Run with: npm test
6
+ ***********************************************************************/
7
+ import { test, expect } from "vitest";
8
+ import {
9
+ yui_asset_id,
10
+ yui_asset_ids,
11
+ yui_asset_src,
12
+ } from "./yui_asset.js";
13
+
14
+ /*
15
+ * A treedb fkey is `topic^id^hook`.
16
+ */
17
+ test("reads the id out of a single-valued fkey", () => {
18
+ expect(yui_asset_id("assets^abc123^as_foto")).toBe("abc123");
19
+ expect(yui_asset_ids("assets^abc123^as_foto")).toEqual(["abc123"]);
20
+ });
21
+
22
+ test("reads every id out of an array fkey, in order", () => {
23
+ expect(yui_asset_ids([
24
+ "assets^p1^as_plano",
25
+ "assets^p2^as_plano",
26
+ ])).toEqual(["p1", "p2"]);
27
+ expect(yui_asset_id(["assets^p1^as_plano", "assets^p2^as_plano"])).toBe("p1");
28
+ });
29
+
30
+ /*
31
+ * `fkey_only_id` is a normal way to read a node -- yunovatios reads every
32
+ * device that way -- and it collapses a link to the bare id. Reading only
33
+ * the `topic^id^hook` form made every such link look like no link at all.
34
+ */
35
+ test("reads a link collapsed by fkey_only_id, list or bare", () => {
36
+ expect(yui_asset_id(["7b09b140"])).toBe("7b09b140");
37
+ expect(yui_asset_id("7b09b140")).toBe("7b09b140");
38
+ expect(yui_asset_ids(["p1", "p2"])).toEqual(["p1", "p2"]);
39
+ });
40
+
41
+ /*
42
+ * An UNSET single-valued fkey comes back as an EMPTY LIST, not as an empty
43
+ * string: that is the shape of "this device has no photo".
44
+ */
45
+ test("an unset single-valued fkey is an empty list", () => {
46
+ expect(yui_asset_id([])).toBe(null);
47
+ expect(yui_asset_ids([])).toEqual([]);
48
+ });
49
+
50
+ test("reads an EXPANDED ref, which is what the refs options give back", () => {
51
+ expect(yui_asset_id({id: "abc123", content_type: "image/jpeg"})).toBe("abc123");
52
+ });
53
+
54
+ /*
55
+ * An empty column is the normal state of a device nobody photographed. It
56
+ * must answer "nothing", never throw and never a half-parsed id.
57
+ */
58
+ test("an empty or malformed column yields nothing, and does not throw", () => {
59
+ expect(yui_asset_ids(null)).toEqual([]);
60
+ expect(yui_asset_ids(undefined)).toEqual([]);
61
+ expect(yui_asset_ids("")).toEqual([]);
62
+ expect(yui_asset_ids([])).toEqual([]);
63
+ expect(yui_asset_id(null)).toBe(null);
64
+ expect(yui_asset_id({})).toBe(null);
65
+ expect(yui_asset_ids(["assets^ok^as_foto", null, ""])).toEqual(["ok"]);
66
+ });
67
+
68
+ /*
69
+ * The two shapes the backend answers with, and the ONE code path they
70
+ * exist to give the caller.
71
+ */
72
+ test("a signed url is used as it comes", () => {
73
+ expect(yui_asset_src({
74
+ mode: "url",
75
+ url: "/assets/ab/cd/abcd.jpg?e=1&s=tok",
76
+ })).toBe("/assets/ab/cd/abcd.jpg?e=1&s=tok");
77
+ });
78
+
79
+ test("inline bytes become a data url carrying their own type", () => {
80
+ expect(yui_asset_src({
81
+ mode: "inline",
82
+ content_type: "image/jpeg",
83
+ content64: "AAAA",
84
+ })).toBe("data:image/jpeg;base64,AAAA");
85
+ });
86
+
87
+ /*
88
+ * Anything else is MISSING, and must come back as null rather than as an
89
+ * empty string: <img src=""> reloads the page in some browsers, which is a
90
+ * worse failure than the one being reported.
91
+ */
92
+ test("an unusable answer is null, never an empty src", () => {
93
+ expect(yui_asset_src(null)).toBe(null);
94
+ expect(yui_asset_src({})).toBe(null);
95
+ expect(yui_asset_src({mode: "url"})).toBe(null);
96
+ expect(yui_asset_src({mode: "inline", content_type: "image/png"})).toBe(null);
97
+ expect(yui_asset_src({mode: "url", url: ""})).toBe(null);
98
+ });