@k2b/cloud 0.22.0 → 0.24.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -475,6 +475,76 @@ const isTruthy = (v: EvalValue): boolean => {
|
|
|
475
475
|
return v.length > 0;
|
|
476
476
|
};
|
|
477
477
|
|
|
478
|
+
/** Cell text that is one decimal number and nothing else. Each digit has
|
|
479
|
+
* one place in the pattern, so a long run of digits never backtracks. */
|
|
480
|
+
const NUMBER_CELL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
481
|
+
|
|
482
|
+
/** A number, boolean, or text whose whole content is one number. */
|
|
483
|
+
const toExactNumber = (v: EvalValue): number | null =>
|
|
484
|
+
typeof v === "string" ? (NUMBER_CELL.test(v.trim()) ? Number(v.trim()) : null) : toNumber(v);
|
|
485
|
+
|
|
486
|
+
/** An ISO date or timestamp: `2026-10-12`, `2026-10-12 09:30`,
|
|
487
|
+
* `2026-10-12T09:30:00.250Z`, or with an offset such as `+02:00`. */
|
|
488
|
+
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})\d*)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
|
|
489
|
+
|
|
490
|
+
/** An ISO date without a time, such as `2026-10-12`. */
|
|
491
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The instant an ISO date or timestamp names, or null for anything else,
|
|
495
|
+
* including impossible dates such as `2026-02-30` and offsets such as
|
|
496
|
+
* `+00:60`. Text without an offset is local time, like the values TODAY()
|
|
497
|
+
* and NOW() return.
|
|
498
|
+
*/
|
|
499
|
+
const toDateTime = (v: EvalValue): number | null => {
|
|
500
|
+
if (typeof v !== "string") return null;
|
|
501
|
+
const match = ISO_DATE_TIME.exec(v.trim());
|
|
502
|
+
if (!match) return null;
|
|
503
|
+
const year = Number(match[1]);
|
|
504
|
+
const month = Number(match[2]);
|
|
505
|
+
const day = Number(match[3]);
|
|
506
|
+
const hour = Number(match[4] ?? 0);
|
|
507
|
+
const minute = Number(match[5] ?? 0);
|
|
508
|
+
const second = Number(match[6] ?? 0);
|
|
509
|
+
const ms = Number((match[7] ?? "").padEnd(3, "0"));
|
|
510
|
+
const zone = match[8];
|
|
511
|
+
if (hour > 23 || minute > 59 || second > 59) return null;
|
|
512
|
+
const utc = new Date(0);
|
|
513
|
+
utc.setUTCFullYear(year, month - 1, day);
|
|
514
|
+
if (utc.getUTCMonth() !== month - 1 || utc.getUTCDate() !== day) return null;
|
|
515
|
+
if (zone) {
|
|
516
|
+
utc.setUTCHours(hour, minute, second, ms);
|
|
517
|
+
if (zone.toUpperCase() === "Z") return utc.getTime();
|
|
518
|
+
const sign = zone.startsWith("-") ? -1 : 1;
|
|
519
|
+
const digits = zone.slice(1).replace(":", "");
|
|
520
|
+
const offsetHours = Number(digits.slice(0, 2));
|
|
521
|
+
const offsetMinutes = Number(digits.slice(2));
|
|
522
|
+
if (offsetHours > 23 || offsetMinutes > 59) return null;
|
|
523
|
+
return utc.getTime() - sign * (offsetHours * 60 + offsetMinutes) * 60_000;
|
|
524
|
+
}
|
|
525
|
+
const local = new Date(0);
|
|
526
|
+
local.setFullYear(year, month - 1, day);
|
|
527
|
+
local.setHours(hour, minute, second, ms);
|
|
528
|
+
return local.getTime();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Equality for `==`, `!=`, COUNTIF, and SUMIF: dates by instant; numbers,
|
|
533
|
+
* also `5 €` against 5, by value; everything else, such as `1.2.3` or
|
|
534
|
+
* `3 apples`, by its exact text.
|
|
535
|
+
*/
|
|
536
|
+
const isEqual = (l: EvalValue, r: EvalValue): boolean => {
|
|
537
|
+
const ld = toDateTime(l);
|
|
538
|
+
const rd = toDateTime(r);
|
|
539
|
+
if (ld !== null && rd !== null) return ld === rd;
|
|
540
|
+
if (ld === null && rd === null && (toExactNumber(l) !== null || toExactNumber(r) !== null)) {
|
|
541
|
+
const ln = toNumber(l);
|
|
542
|
+
const rn = toNumber(r);
|
|
543
|
+
if (ln !== null && rn !== null) return ln === rn;
|
|
544
|
+
}
|
|
545
|
+
return toString(l) === toString(r);
|
|
546
|
+
};
|
|
547
|
+
|
|
478
548
|
const lookupColumn = (name: string, ctx: EvalContext): { index: number } | { suggestion?: string } => {
|
|
479
549
|
const lower = name.toLowerCase();
|
|
480
550
|
const idx = ctx.headers.findIndex((h) => h.toLowerCase() === lower);
|
|
@@ -492,8 +562,9 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
|
|
|
492
562
|
/**
|
|
493
563
|
* Resolve a single cell to its evaluated value. If the cell is itself
|
|
494
564
|
* a formula, recursively evaluate it (with cycle detection via
|
|
495
|
-
* `ctx._visited`).
|
|
496
|
-
*
|
|
565
|
+
* `ctx._visited`). A non-formula cell becomes a number only when its
|
|
566
|
+
* whole text is one; anything else, such as the ISO date `2026-10-12`
|
|
567
|
+
* that DATEDIFF needs intact, stays text.
|
|
497
568
|
*
|
|
498
569
|
* Used by both the column-reference resolver in `evaluateAst` and by
|
|
499
570
|
* the column / row aggregate functions, so a `=SUM(total)` aggregating
|
|
@@ -503,8 +574,8 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
|
|
|
503
574
|
const evaluateCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
|
|
504
575
|
const cell = cellAt(row, col, ctx);
|
|
505
576
|
if (!cell.startsWith("=")) {
|
|
506
|
-
const
|
|
507
|
-
return
|
|
577
|
+
const text = cell.trim();
|
|
578
|
+
return NUMBER_CELL.test(text) ? ok(Number(text)) : ok(cell);
|
|
508
579
|
}
|
|
509
580
|
const key = `${row},${col}`;
|
|
510
581
|
if (ctx._visited?.has(key)) {
|
|
@@ -686,9 +757,8 @@ const FUNCTIONS: Record<string, FuncImpl> = {
|
|
|
686
757
|
if ("kind" in col) return col;
|
|
687
758
|
const valueArg = state.evaluate(args[1]!);
|
|
688
759
|
if (valueArg.kind === "error") return valueArg;
|
|
689
|
-
const target = toString(valueArg.value);
|
|
690
760
|
let count = 0;
|
|
691
|
-
for (const v of col.values) if (v
|
|
761
|
+
for (const v of col.values) if (isEqual(v, valueArg.value)) count++;
|
|
692
762
|
return ok(count);
|
|
693
763
|
},
|
|
694
764
|
SUMIF: (args, state) => {
|
|
@@ -717,13 +787,12 @@ const FUNCTIONS: Record<string, FuncImpl> = {
|
|
|
717
787
|
}
|
|
718
788
|
const condValueArg = state.evaluate(args[2]!);
|
|
719
789
|
if (condValueArg.kind === "error") return condValueArg;
|
|
720
|
-
const target = toString(condValueArg.value);
|
|
721
790
|
let total = 0;
|
|
722
791
|
for (let r = 0; r < state.ctx.rows.length; r++) {
|
|
723
792
|
if (isCurrentFormulaCell(r, condLookup.index, state.ctx) || isCurrentFormulaCell(r, sumLookup.index, state.ctx)) continue;
|
|
724
793
|
const condResult = evaluateCell(r, condLookup.index, state.ctx);
|
|
725
794
|
if (condResult.kind !== "ok") continue;
|
|
726
|
-
if (
|
|
795
|
+
if (!isEqual(condResult.value, condValueArg.value)) continue;
|
|
727
796
|
const sumResult = evaluateCell(r, sumLookup.index, state.ctx);
|
|
728
797
|
if (sumResult.kind !== "ok") continue;
|
|
729
798
|
const n = toNumber(sumResult.value);
|
|
@@ -989,17 +1058,25 @@ const FUNCTIONS: Record<string, FuncImpl> = {
|
|
|
989
1058
|
if (d1V.kind === "error") return d1V;
|
|
990
1059
|
const d2V = state.evaluate(args[1]!);
|
|
991
1060
|
if (d2V.kind === "error") return d2V;
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1061
|
+
// ISO values follow the comparison operators, so a date without a
|
|
1062
|
+
// time is local midnight and an impossible one such as 2026-02-30 is
|
|
1063
|
+
// no date; other text keeps the Date parser's reading.
|
|
1064
|
+
const instant = (v: EvalValue): number =>
|
|
1065
|
+
ISO_DATE_TIME.test(toString(v).trim()) ? (toDateTime(v) ?? Number.NaN) : new Date(toString(v)).getTime();
|
|
1066
|
+
const t1 = instant(d1V.value);
|
|
1067
|
+
const t2 = instant(d2V.value);
|
|
1068
|
+
if (Number.isNaN(t1)) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
|
|
1069
|
+
if (Number.isNaN(t2)) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
|
|
996
1070
|
let unit = "days";
|
|
997
1071
|
if (args.length === 3) {
|
|
998
1072
|
const u = state.evaluate(args[2]!);
|
|
999
1073
|
if (u.kind === "error") return u;
|
|
1000
1074
|
unit = toString(u.value).toLowerCase();
|
|
1001
1075
|
}
|
|
1002
|
-
|
|
1076
|
+
// Two dates are whole days apart, also across a daylight saving change.
|
|
1077
|
+
const dayMs = 1000 * 60 * 60 * 24;
|
|
1078
|
+
const wholeDays = ISO_DATE.test(toString(d1V.value).trim()) && ISO_DATE.test(toString(d2V.value).trim());
|
|
1079
|
+
const diffMs = wholeDays ? Math.round((t2 - t1) / dayMs) * dayMs : t2 - t1;
|
|
1003
1080
|
switch (unit) {
|
|
1004
1081
|
case "ms":
|
|
1005
1082
|
case "milliseconds":
|
|
@@ -1030,18 +1107,45 @@ const FUNCTION_NAMES = Object.keys(FUNCTIONS);
|
|
|
1030
1107
|
|
|
1031
1108
|
// -- Binop application --------------------------------------------------------
|
|
1032
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* The two numbers the ordering operators compare, or null when the
|
|
1112
|
+
* values have no common order. Two dates compare as instants and never
|
|
1113
|
+
* with a number or other text, so `2026-10-12` is not `2026`. Text with
|
|
1114
|
+
* a unit, such as `5 €`, compares by its leading number, as does text
|
|
1115
|
+
* against text: `5 €` and `5.00 €` are neither smaller nor greater than
|
|
1116
|
+
* each other, even though `==` tells their texts apart.
|
|
1117
|
+
*/
|
|
1118
|
+
const orderedPair = (l: EvalValue, r: EvalValue): [number, number] | null => {
|
|
1119
|
+
const ld = toDateTime(l);
|
|
1120
|
+
const rd = toDateTime(r);
|
|
1121
|
+
if (ld !== null || rd !== null) return ld !== null && rd !== null ? [ld, rd] : null;
|
|
1122
|
+
const ln = toNumber(l);
|
|
1123
|
+
const rn = toNumber(r);
|
|
1124
|
+
return ln !== null && rn !== null ? [ln, rn] : null;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1033
1127
|
const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
|
|
1034
1128
|
if (op === "==" || op === "!=") {
|
|
1035
|
-
|
|
1036
|
-
const ln = toNumber(l);
|
|
1037
|
-
const rn = toNumber(r);
|
|
1038
|
-
const equal = ln !== null && rn !== null ? ln === rn : toString(l) === toString(r);
|
|
1129
|
+
const equal = isEqual(l, r);
|
|
1039
1130
|
return ok((op === "==" ? equal : !equal) ? 1 : 0);
|
|
1040
1131
|
}
|
|
1132
|
+
if (op === "<" || op === "<=" || op === ">" || op === ">=") {
|
|
1133
|
+
const pair = orderedPair(l, r);
|
|
1134
|
+
if (pair === null) {
|
|
1135
|
+
return toDateTime(l) !== null || toDateTime(r) !== null
|
|
1136
|
+
? err("TYPE_ERROR", `Cannot compare a date with a value that is not a date using "${op}"`)
|
|
1137
|
+
: err("NON_NUMERIC", `Cannot compare non-numeric values with "${op}"`);
|
|
1138
|
+
}
|
|
1139
|
+
const [a, b] = pair;
|
|
1140
|
+
if (op === "<") return ok(a < b ? 1 : 0);
|
|
1141
|
+
if (op === "<=") return ok(a <= b ? 1 : 0);
|
|
1142
|
+
if (op === ">") return ok(a > b ? 1 : 0);
|
|
1143
|
+
return ok(a >= b ? 1 : 0);
|
|
1144
|
+
}
|
|
1041
1145
|
const ln = toNumber(l);
|
|
1042
1146
|
const rn = toNumber(r);
|
|
1043
1147
|
if (ln === null || rn === null) {
|
|
1044
|
-
return err("NON_NUMERIC", `Cannot
|
|
1148
|
+
return err("NON_NUMERIC", `Cannot compute non-numeric values with "${op}"`);
|
|
1045
1149
|
}
|
|
1046
1150
|
switch (op) {
|
|
1047
1151
|
case "+":
|
|
@@ -1053,14 +1157,6 @@ const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
|
|
|
1053
1157
|
case "/":
|
|
1054
1158
|
if (rn === 0) return err("DIV_BY_ZERO", `Division by zero`);
|
|
1055
1159
|
return ok(ln / rn);
|
|
1056
|
-
case "<":
|
|
1057
|
-
return ok(ln < rn ? 1 : 0);
|
|
1058
|
-
case "<=":
|
|
1059
|
-
return ok(ln <= rn ? 1 : 0);
|
|
1060
|
-
case ">":
|
|
1061
|
-
return ok(ln > rn ? 1 : 0);
|
|
1062
|
-
case ">=":
|
|
1063
|
-
return ok(ln >= rn ? 1 : 0);
|
|
1064
1160
|
}
|
|
1065
1161
|
};
|
|
1066
1162
|
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* the visual appearance of the CodeMirror editor extensions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { markdownInfoBlocks } from "@k2b/ui";
|
|
9
|
+
import { Marked, type MarkedExtension } from "marked";
|
|
9
10
|
import sanitizeHtml from "sanitize-html";
|
|
10
11
|
import { markdownClient } from "./client";
|
|
11
12
|
import { codeExtension } from "./extensions/code";
|
|
12
13
|
import { guidedHelpExtension } from "./extensions/guided-help";
|
|
13
14
|
import { imagesExtension } from "./extensions/images";
|
|
14
|
-
import { infoBlocksExtension, type NoticeStyle } from "./extensions/info-blocks";
|
|
15
15
|
import { katexExtension } from "./extensions/katex";
|
|
16
16
|
import { linksExtension } from "./extensions/links";
|
|
17
17
|
import { markExtension } from "./extensions/mark";
|
|
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
|
|
|
23
23
|
type MarkdownProfile = "content" | "help";
|
|
24
24
|
type LinkStyle = "widget" | "plain";
|
|
25
25
|
|
|
26
|
-
const createMarked = (profile: MarkdownProfile
|
|
26
|
+
const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension) => {
|
|
27
27
|
const marked = new Marked();
|
|
28
28
|
|
|
29
29
|
marked.use({
|
|
@@ -33,7 +33,7 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
|
|
|
33
33
|
|
|
34
34
|
// Apply extensions in order
|
|
35
35
|
// Note: katexExtension must come before codeExtension to handle ```math blocks
|
|
36
|
-
marked.use(
|
|
36
|
+
marked.use(infoBlocks);
|
|
37
37
|
marked.use(taskListExtension());
|
|
38
38
|
marked.use(tablesExtension());
|
|
39
39
|
// Plain links keep marked's own renderer: an anchor around the link text.
|
|
@@ -49,24 +49,27 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
|
|
|
49
49
|
return marked;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// One instance per UI message catalog, not per requested locale: request
|
|
53
|
+
// headers choose the locale, and `markdownInfoBlocks()` returns one extension
|
|
54
|
+
// for every locale that shares a catalog, so the cache stays bounded.
|
|
55
|
+
const instances = new Map<MarkedExtension, Map<string, Marked>>();
|
|
56
|
+
const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string): Marked => {
|
|
57
|
+
const infoBlocks = markdownInfoBlocks({ locale });
|
|
58
|
+
const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
|
|
59
|
+
instances.set(infoBlocks, byStyle);
|
|
60
|
+
const key = `${profile}:${links}`;
|
|
61
|
+
const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks);
|
|
62
|
+
byStyle.set(key, instance);
|
|
63
|
+
return instance;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const marked = markedFor("content");
|
|
55
67
|
|
|
56
68
|
export type MarkdownRenderOptions = {
|
|
57
|
-
/** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
|
|
58
|
-
notices?: NoticeStyle;
|
|
59
69
|
/** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
|
|
60
70
|
links?: LinkStyle;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const markedFor = ({ notices = "card", links = "widget" }: MarkdownRenderOptions): Marked => {
|
|
64
|
-
const key = `${notices}:${links}` as const;
|
|
65
|
-
const cached = contentMarked.get(key);
|
|
66
|
-
if (cached) return cached;
|
|
67
|
-
const created = createMarked("content", notices, links);
|
|
68
|
-
contentMarked.set(key, created);
|
|
69
|
-
return created;
|
|
71
|
+
/** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
|
|
72
|
+
locale?: string;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
const sanitizeRenderedHtml = (html: string): string =>
|
|
@@ -145,7 +148,8 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
145
148
|
*
|
|
146
149
|
* Supported features:
|
|
147
150
|
* - GFM (GitHub Flavored Markdown)
|
|
148
|
-
* -
|
|
151
|
+
* - Info blocks (:::note, :::info, :::success, :::warning, :::danger), shared
|
|
152
|
+
* with `MarkdownView` from `@k2b/ui`
|
|
149
153
|
* - Task lists with checkboxes
|
|
150
154
|
* - Tables with cell formatting
|
|
151
155
|
* - Styled links and images
|
|
@@ -169,7 +173,7 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
169
173
|
export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
|
|
170
174
|
if (!content || typeof content !== "string") return "";
|
|
171
175
|
|
|
172
|
-
const html = markedFor(options).parse(content);
|
|
176
|
+
const html = markedFor("content", options.links, options.locale).parse(content);
|
|
173
177
|
if (typeof html !== "string") return "";
|
|
174
178
|
|
|
175
179
|
return sanitizeRenderedHtml(html);
|
|
@@ -181,7 +185,7 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
|
|
|
181
185
|
export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
|
|
182
186
|
if (!content || typeof content !== "string") return "";
|
|
183
187
|
|
|
184
|
-
const html = markedFor(options).parse(content);
|
|
188
|
+
const html = markedFor("content", options.links, options.locale).parse(content);
|
|
185
189
|
if (typeof html !== "string") return "";
|
|
186
190
|
|
|
187
191
|
return sanitizeRenderedHtml(html);
|
|
@@ -191,9 +195,9 @@ export function renderMarkdownSync(content: string, options: MarkdownRenderOptio
|
|
|
191
195
|
* Render trusted documentation Markdown with guided sections and internal
|
|
192
196
|
* navigation. All fenced code remains visible source, as in ordinary Markdown.
|
|
193
197
|
*/
|
|
194
|
-
export function renderHelpMarkdown(content: string): string {
|
|
198
|
+
export function renderHelpMarkdown(content: string, locale?: string): string {
|
|
195
199
|
if (!content || typeof content !== "string") return "";
|
|
196
|
-
const html =
|
|
200
|
+
const html = markedFor("help", "widget", locale).parse(content);
|
|
197
201
|
return typeof html === "string" ? sanitizeRenderedHtml(html) : "";
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -27,7 +27,14 @@ const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
|
|
|
27
27
|
if (!Array.isArray(parsed.apps)) return undefined;
|
|
28
28
|
return {
|
|
29
29
|
apps: parsed.apps,
|
|
30
|
-
profile:
|
|
30
|
+
profile:
|
|
31
|
+
parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light")
|
|
32
|
+
? {
|
|
33
|
+
name: parsed.profile.name,
|
|
34
|
+
theme: parsed.profile.theme,
|
|
35
|
+
...(parsed.profile.appHref === "/me/app" ? { appHref: parsed.profile.appHref } : {}),
|
|
36
|
+
}
|
|
37
|
+
: undefined,
|
|
31
38
|
legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
|
|
32
39
|
};
|
|
33
40
|
} catch {
|
|
@@ -26,7 +26,7 @@ export type AppLaunchpadLegalLink = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export type AppLaunchpadContext = {
|
|
29
|
-
profile?: { name: string; theme: CloudTheme };
|
|
29
|
+
profile?: { name: string; theme: CloudTheme; appHref?: string };
|
|
30
30
|
apps: AppLaunchpadApp[];
|
|
31
31
|
legalLinks: AppLaunchpadLegalLink[];
|
|
32
32
|
};
|
|
@@ -102,7 +102,7 @@ export default function GlobalAnnouncements(props: Props) {
|
|
|
102
102
|
<MarkdownView
|
|
103
103
|
trustedHtml={banner.bodyHtml}
|
|
104
104
|
headingScale="compact"
|
|
105
|
-
class="
|
|
105
|
+
class="max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
|
|
106
106
|
/>
|
|
107
107
|
</NoticeCard>
|
|
108
108
|
<IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { JSX } from "solid-js/jsx-runtime";
|
|
|
4
4
|
import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
|
|
5
5
|
import { navigationSearchItems } from "../browser/navigation-search";
|
|
6
6
|
import { resolveNavMatch } from "../contracts/app"; // ==========================
|
|
7
|
+
import { isPwaShellAvailable } from "../contracts/pwa";
|
|
7
8
|
import { defaultRailPreferences } from "../contracts/rail-preferences";
|
|
8
9
|
import { hasRole, type User } from "../contracts/shared";
|
|
9
10
|
import { getLocale } from "../server/locale";
|
|
@@ -90,9 +91,7 @@ function ProfileWarnings({ user, locale }: { user: User; locale: string }) {
|
|
|
90
91
|
if (missing.length === 0) return null;
|
|
91
92
|
return (
|
|
92
93
|
<a href="/me" class="block shrink-0 no-underline">
|
|
93
|
-
<NoticeCard tone="warning"
|
|
94
|
-
<i class="ti ti-user-exclamation" /> <span>{t.profileIncomplete({ fields: missing.join(", ") })}</span>
|
|
95
|
-
</NoticeCard>
|
|
94
|
+
<NoticeCard tone="warning">{t.profileIncomplete({ fields: missing.join(", ") })}</NoticeCard>
|
|
96
95
|
</a>
|
|
97
96
|
);
|
|
98
97
|
}
|
|
@@ -100,13 +99,12 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
100
99
|
const t = platformMessages.resolve([locale]).t;
|
|
101
100
|
const now = Date.now();
|
|
102
101
|
const warnThreshold = now + WARN_DAYS * 24 * 60 * 60 * 1000;
|
|
103
|
-
const warnings: {
|
|
102
|
+
const warnings: { message: string; expired: boolean }[] = [];
|
|
104
103
|
if (user.accountExpires) {
|
|
105
104
|
const expires = new Date(user.accountExpires).getTime();
|
|
106
|
-
if (expires < now) warnings.push({
|
|
105
|
+
if (expires < now) warnings.push({ message: t.accountExpired, expired: true });
|
|
107
106
|
else if (expires < warnThreshold)
|
|
108
107
|
warnings.push({
|
|
109
|
-
icon: "ti-calendar-event",
|
|
110
108
|
message:
|
|
111
109
|
user.profile === "guest"
|
|
112
110
|
? t.guestAccountExpires({ date: dates.formatDate(user.accountExpires, dateConfig) })
|
|
@@ -116,10 +114,9 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
116
114
|
}
|
|
117
115
|
if (user.ipa?.passwordExpires) {
|
|
118
116
|
const expires = new Date(user.ipa.passwordExpires).getTime();
|
|
119
|
-
if (expires < now) warnings.push({
|
|
117
|
+
if (expires < now) warnings.push({ message: t.passwordExpired, expired: true });
|
|
120
118
|
else if (expires < warnThreshold)
|
|
121
119
|
warnings.push({
|
|
122
|
-
icon: "ti-key",
|
|
123
120
|
message: t.passwordExpires({ date: dates.formatDate(user.ipa.passwordExpires, dateConfig) }),
|
|
124
121
|
expired: false,
|
|
125
122
|
});
|
|
@@ -129,10 +126,7 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
129
126
|
<div class="flex shrink-0 flex-col gap-1">
|
|
130
127
|
{" "}
|
|
131
128
|
{warnings.map((w) => (
|
|
132
|
-
<NoticeCard tone={w.expired ? "danger" : "warning"}
|
|
133
|
-
{" "}
|
|
134
|
-
<i class={`ti ${w.icon}`} /> <span>{w.message}</span>{" "}
|
|
135
|
-
</NoticeCard>
|
|
129
|
+
<NoticeCard tone={w.expired ? "danger" : "warning"}>{w.message}</NoticeCard>
|
|
136
130
|
))}{" "}
|
|
137
131
|
</div>
|
|
138
132
|
);
|
|
@@ -205,6 +199,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
205
199
|
if (!page.title) page.title = pageTitle;
|
|
206
200
|
const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
|
|
207
201
|
const showRail = !!user;
|
|
202
|
+
// The profile menu offers the mobile app only while the installation runs it.
|
|
203
|
+
const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
|
|
208
204
|
const profileName = user?.displayName || user?.uid || "?";
|
|
209
205
|
const profileAvatarSrc =
|
|
210
206
|
user?.avatarHash && user.id
|
|
@@ -253,6 +249,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
253
249
|
{showRail && (
|
|
254
250
|
<LayoutRail
|
|
255
251
|
accent={currentApp?.appearance?.accent}
|
|
252
|
+
appHref={appHref}
|
|
256
253
|
appsLabel={t.apps}
|
|
257
254
|
homeLabel={t.home}
|
|
258
255
|
launchpadApps={launchpadApps}
|
|
@@ -270,6 +267,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
270
267
|
)}
|
|
271
268
|
<div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
|
|
272
269
|
<LayoutHeader
|
|
270
|
+
appHref={appHref}
|
|
273
271
|
appLabel={currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined)}
|
|
274
272
|
accent={currentApp?.appearance?.accent}
|
|
275
273
|
authenticated={showRail}
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import ProfilePreferences from "./ProfilePreferences.island";
|
|
|
12
12
|
|
|
13
13
|
type LayoutHeaderProps = {
|
|
14
14
|
accent?: string;
|
|
15
|
+
/** Set while the installation offers the mobile app. */
|
|
16
|
+
appHref?: string;
|
|
15
17
|
appLabel?: string;
|
|
16
18
|
authenticated: boolean;
|
|
17
19
|
breadcrumbs: LayoutBreadcrumb[];
|
|
@@ -64,7 +66,11 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
64
66
|
/>
|
|
65
67
|
{(props.authenticated || props.appLabel) && (
|
|
66
68
|
<AppLaunchpad
|
|
67
|
-
profile={
|
|
69
|
+
profile={
|
|
70
|
+
props.authenticated
|
|
71
|
+
? { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) }
|
|
72
|
+
: undefined
|
|
73
|
+
}
|
|
68
74
|
apps={props.launchpadApps}
|
|
69
75
|
legalLinks={props.legalLinks}
|
|
70
76
|
variant="header"
|
|
@@ -74,7 +80,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
74
80
|
</div>
|
|
75
81
|
{props.authenticated ? (
|
|
76
82
|
<div class="hidden lg:block">
|
|
77
|
-
<ProfilePreferences
|
|
83
|
+
<ProfilePreferences
|
|
84
|
+
appHref={props.appHref}
|
|
85
|
+
avatarSrc={props.profileAvatarSrc}
|
|
86
|
+
initialTheme={props.theme}
|
|
87
|
+
name={props.profileName}
|
|
88
|
+
placement="header"
|
|
89
|
+
/>
|
|
78
90
|
</div>
|
|
79
91
|
) : (
|
|
80
92
|
<>
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -753,10 +753,8 @@ const HelpShell = (props: {
|
|
|
753
753
|
</Show>
|
|
754
754
|
<Show when={loadError()}>
|
|
755
755
|
{(message) => (
|
|
756
|
-
<NoticeCard tone="danger"
|
|
757
|
-
<
|
|
758
|
-
<p class="mt-1 text-sm">{message()}</p>
|
|
759
|
-
<Button size="sm" variant="secondary" class="mt-3" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
756
|
+
<NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
|
|
757
|
+
<Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
760
758
|
{t().tryAgain}
|
|
761
759
|
</Button>
|
|
762
760
|
</NoticeCard>
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -12,6 +12,8 @@ export type LayoutAppLink = RailApp;
|
|
|
12
12
|
|
|
13
13
|
type LayoutRailProps = {
|
|
14
14
|
accent?: string;
|
|
15
|
+
/** Set while the installation offers the mobile app. */
|
|
16
|
+
appHref?: string;
|
|
15
17
|
appsLabel: string;
|
|
16
18
|
homeLabel: string;
|
|
17
19
|
launchpadApps: AppLaunchpadApp[];
|
|
@@ -31,11 +33,12 @@ type LayoutRailProps = {
|
|
|
31
33
|
const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
32
34
|
|
|
33
35
|
export default function LayoutRail(props: LayoutRailProps) {
|
|
36
|
+
const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
|
|
34
37
|
return (
|
|
35
38
|
<>
|
|
36
|
-
<AppLaunchpad profile={
|
|
39
|
+
<AppLaunchpad profile={profile} apps={props.launchpadApps} legalLinks={props.legalLinks} />
|
|
37
40
|
<script id="cloud-app-launchpad-data" type="application/json">
|
|
38
|
-
{jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile
|
|
41
|
+
{jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile })}
|
|
39
42
|
</script>
|
|
40
43
|
<script id="cloud-rail-data" type="application/json">
|
|
41
44
|
{jsonScript({ apps: props.apps, settings: props.railSettings })}
|
|
@@ -49,7 +52,7 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
49
52
|
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
|
|
50
53
|
<RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
|
|
51
54
|
<AppLaunchpad
|
|
52
|
-
profile={
|
|
55
|
+
profile={profile}
|
|
53
56
|
apps={props.launchpadApps}
|
|
54
57
|
legalLinks={props.legalLinks}
|
|
55
58
|
variant="rail"
|
|
@@ -58,7 +61,13 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
58
61
|
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
59
62
|
<GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
|
|
60
63
|
<LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
|
|
61
|
-
<ProfilePreferences
|
|
64
|
+
<ProfilePreferences
|
|
65
|
+
appHref={props.appHref}
|
|
66
|
+
avatarSrc={props.profileAvatarSrc}
|
|
67
|
+
initialTheme={props.theme}
|
|
68
|
+
name={props.profileName}
|
|
69
|
+
placement="rail"
|
|
70
|
+
/>
|
|
62
71
|
</div>
|
|
63
72
|
</nav>
|
|
64
73
|
</aside>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Button, Dropdown, useLocale } from "@k2b/ui";
|
|
2
|
-
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
|
|
3
2
|
import { canonicalLocale } from "../shared/locale";
|
|
3
|
+
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
4
|
import type { CloudTheme } from "../shared/theme";
|
|
5
5
|
import { createPreferenceController } from "./preference-controller";
|
|
6
6
|
|
|
@@ -3,24 +3,30 @@ import { Button, ButtonLink } from "@k2b/ui";
|
|
|
3
3
|
import type { CloudTheme } from "../shared/theme";
|
|
4
4
|
import { createProfileActions } from "./profile-actions";
|
|
5
5
|
|
|
6
|
-
export function MobileProfileActions(props: {
|
|
7
|
-
|
|
6
|
+
export function MobileProfileActions(props: {
|
|
7
|
+
profile: { name: string; theme: CloudTheme; appHref?: string };
|
|
8
|
+
beforeSelect: () => Promise<boolean>;
|
|
9
|
+
}) {
|
|
10
|
+
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
|
|
11
|
+
const navigate = (href: string) => (event: MouseEvent) => {
|
|
12
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
void props.beforeSelect().then((allowed) => {
|
|
15
|
+
if (allowed) documentNavigate(href);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
8
18
|
return (
|
|
9
19
|
<section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
|
|
10
|
-
<ButtonLink
|
|
11
|
-
href="/me"
|
|
12
|
-
variant="ghost"
|
|
13
|
-
onClick={(event) => {
|
|
14
|
-
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
15
|
-
event.preventDefault();
|
|
16
|
-
void props.beforeSelect().then((allowed) => {
|
|
17
|
-
if (allowed) documentNavigate("/me");
|
|
18
|
-
});
|
|
19
|
-
}}
|
|
20
|
-
>
|
|
20
|
+
<ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
|
|
21
21
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
22
22
|
{preferences.messages().profileSettings}
|
|
23
23
|
</ButtonLink>
|
|
24
|
+
{props.profile.appHref && (
|
|
25
|
+
<ButtonLink href={props.profile.appHref} variant="ghost" onClick={navigate(props.profile.appHref)}>
|
|
26
|
+
<i class="ti ti-device-mobile" aria-hidden="true" />
|
|
27
|
+
{preferences.messages().app}
|
|
28
|
+
</ButtonLink>
|
|
29
|
+
)}
|
|
24
30
|
<Button variant="ghost" onClick={preferences.toggleTheme}>
|
|
25
31
|
<i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
|
|
26
32
|
{preferences.themeLabel()}
|
package/src/ssr/PageError.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { NotFoundState } from "@k2b/ui";
|
|
1
|
+
import { ButtonLink, NotFoundState, Placeholder } from "@k2b/ui";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { PageErrorOptions, PageErrorStatus } from "../_internal/page-responses";
|
|
4
|
+
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
5
|
import { getLocale } from "../server/locale";
|
|
5
6
|
import Layout from "./Layout";
|
|
6
7
|
import MinimalLayout from "./MinimalLayout";
|
|
8
|
+
import PwaLayout from "./PwaLayout";
|
|
7
9
|
import { pageErrorMessages } from "./page-error-messages";
|
|
10
|
+
import { pwaMessages } from "./pwa-messages";
|
|
8
11
|
|
|
9
12
|
export const renderPageError = (c: Context, status: PageErrorStatus, options: PageErrorOptions) => {
|
|
10
13
|
const { t } = pageErrorMessages.resolve([getLocale(c)]);
|
|
@@ -14,6 +17,25 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
|
|
|
14
17
|
const action = options.action ?? { label: t.home, href: "/" };
|
|
15
18
|
c.get("page").title = title;
|
|
16
19
|
|
|
20
|
+
if (options.layout === "pwa") {
|
|
21
|
+
const pwa = pwaMessages.resolve([getLocale(c)]).t;
|
|
22
|
+
return () => (
|
|
23
|
+
<PwaLayout c={c} title={title}>
|
|
24
|
+
<Placeholder
|
|
25
|
+
state={status >= 500 ? "error" : "empty"}
|
|
26
|
+
icon={status === 403 ? "ti ti-lock" : status === 404 ? "ti ti-compass-off" : undefined}
|
|
27
|
+
title={title}
|
|
28
|
+
description={description}
|
|
29
|
+
action={
|
|
30
|
+
<ButtonLink href={options.action?.href ?? PWA_SCOPE} variant="secondary">
|
|
31
|
+
{options.action?.label ?? pwa.errorAction}
|
|
32
|
+
</ButtonLink>
|
|
33
|
+
}
|
|
34
|
+
/>
|
|
35
|
+
</PwaLayout>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
return () =>
|
|
18
40
|
options.layout === "minimal" ? (
|
|
19
41
|
<MinimalLayout c={c}>
|