mui-atelier 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +282 -0
- package/dist/components/AddressFields.js +83 -0
- package/dist/components/AuthCard.js +94 -0
- package/dist/components/AuthSplash.js +88 -0
- package/dist/components/BrandHeader.js +81 -0
- package/dist/components/BrandLink.js +22 -0
- package/dist/components/BrandMark.js +69 -0
- package/dist/components/BrandProvider.js +14 -0
- package/dist/components/Breadcrumbs.js +62 -0
- package/dist/components/CardHead.js +46 -0
- package/dist/components/ConfirmProvider.js +77 -0
- package/dist/components/DashboardLayout.js +645 -0
- package/dist/components/DataTable.js +546 -0
- package/dist/components/DetailRows.js +42 -0
- package/dist/components/EmptyNote.js +28 -0
- package/dist/components/Eyebrow.js +21 -0
- package/dist/components/HeadingHelp.js +38 -0
- package/dist/components/HeadingLevel.js +18 -0
- package/dist/components/IconTile.js +42 -0
- package/dist/components/ImagePreviewDialog.js +86 -0
- package/dist/components/LabeledField.js +156 -0
- package/dist/components/LanguageSwitcher.js +43 -0
- package/dist/components/ListRow.js +59 -0
- package/dist/components/ModeToggle.js +35 -0
- package/dist/components/PageHeader.js +65 -0
- package/dist/components/PasswordField.js +43 -0
- package/dist/components/PublicLayout.js +27 -0
- package/dist/components/PublicNav.js +17 -0
- package/dist/components/RouteMessage.js +31 -0
- package/dist/components/RowList.js +10 -0
- package/dist/components/SafeHtml.js +57 -0
- package/dist/components/SaveState.js +13 -0
- package/dist/components/SectionPanel.js +48 -0
- package/dist/components/SectionTabs.js +139 -0
- package/dist/components/SiteNav.js +130 -0
- package/dist/components/Skeletons.js +63 -0
- package/dist/components/Sortable.js +81 -0
- package/dist/components/StatTile.js +66 -0
- package/dist/components/StatusChip.js +17 -0
- package/dist/components/StatusDot.js +42 -0
- package/dist/components/SubCard.js +30 -0
- package/dist/components/TabbedCard.js +20 -0
- package/dist/components/TableFilters.js +247 -0
- package/dist/components/ToastProvider.js +65 -0
- package/dist/eslint/chipRules.mjs +65 -0
- package/dist/hooks/useDocumentTitle.js +19 -0
- package/dist/hooks/useOverflowTabs.js +53 -0
- package/dist/hooks/useUnsavedChanges.js +46 -0
- package/dist/i18n/loadMessages.js +15 -0
- package/dist/i18n/mergeMessages.js +13 -0
- package/dist/i18n/messages/en.json +103 -0
- package/dist/i18n/messages/es.json +103 -0
- package/dist/i18n/messages/zh.json +103 -0
- package/dist/lib/authCard.js +4 -0
- package/dist/lib/chipTone.js +46 -0
- package/dist/lib/countryRegionData.js +29 -0
- package/dist/lib/countryRegionData.json +1 -0
- package/dist/lib/navLock.js +22 -0
- package/dist/lib/navTabSx.js +78 -0
- package/dist/lib/orgTime.js +243 -0
- package/dist/lib/pageHtml.js +139 -0
- package/dist/lib/safeNext.js +13 -0
- package/dist/lib/sanitizeHtml.js +36 -0
- package/dist/lib/slugify.js +7 -0
- package/dist/lib/tableFilter.js +147 -0
- package/dist/lib/tableStyle.js +87 -0
- package/dist/test/jsdomShims.js +53 -0
- package/dist/test/themeContract.js +178 -0
- package/dist/theme/OrgThemeRegistry.js +71 -0
- package/dist/theme/ThemeRegistry.js +17 -0
- package/dist/theme/berryNavRow.js +21 -0
- package/dist/theme/createSiteTheme.js +974 -0
- package/dist/theme/themes.js +438 -0
- package/package.json +118 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
4
|
+
const NavLockedContext = createContext(false);
|
|
5
|
+
const SetNavLockContext = createContext(() => {
|
|
6
|
+
});
|
|
7
|
+
function NavLockProvider({ children }) {
|
|
8
|
+
const [locked, setLocked] = useState(false);
|
|
9
|
+
const value = useMemo(() => setLocked, []);
|
|
10
|
+
return /* @__PURE__ */ jsx(SetNavLockContext.Provider, { value, children: /* @__PURE__ */ jsx(NavLockedContext.Provider, { value: locked, children }) });
|
|
11
|
+
}
|
|
12
|
+
function useNavLock() {
|
|
13
|
+
return useContext(SetNavLockContext);
|
|
14
|
+
}
|
|
15
|
+
function useNavLocked() {
|
|
16
|
+
return useContext(NavLockedContext);
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
NavLockProvider,
|
|
20
|
+
useNavLock,
|
|
21
|
+
useNavLocked
|
|
22
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const WELL_PAD = 4;
|
|
2
|
+
function wellSx(theme) {
|
|
3
|
+
const radius = theme.rt?.radii?.button ?? 8;
|
|
4
|
+
return {
|
|
5
|
+
display: "inline-flex",
|
|
6
|
+
alignItems: "center",
|
|
7
|
+
maxWidth: "100%",
|
|
8
|
+
p: `${WELL_PAD}px`,
|
|
9
|
+
gap: `${WELL_PAD}px`,
|
|
10
|
+
borderRadius: `${radius + WELL_PAD}px`,
|
|
11
|
+
bgcolor: "rgba(0, 0, 0, 0.05)",
|
|
12
|
+
...theme.applyStyles?.("dark", { backgroundColor: "rgba(0, 0, 0, 0.32)" }),
|
|
13
|
+
border: "1px solid",
|
|
14
|
+
borderColor: "divider"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function wellTabSx(theme, active) {
|
|
18
|
+
const radius = theme.rt?.radii?.button ?? 8;
|
|
19
|
+
return {
|
|
20
|
+
display: "inline-flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
gap: 0.5,
|
|
23
|
+
minHeight: 34,
|
|
24
|
+
px: 1.75,
|
|
25
|
+
borderRadius: `${radius}px`,
|
|
26
|
+
// Always a border, so activation changes colour and never width.
|
|
27
|
+
border: "1px solid transparent",
|
|
28
|
+
whiteSpace: "nowrap",
|
|
29
|
+
textDecoration: "none",
|
|
30
|
+
fontSize: "0.9rem",
|
|
31
|
+
fontWeight: 600,
|
|
32
|
+
lineHeight: 1,
|
|
33
|
+
color: "text.secondary",
|
|
34
|
+
cursor: "pointer",
|
|
35
|
+
transition: "color 150ms ease, background-color 150ms ease, box-shadow 150ms ease",
|
|
36
|
+
"&:hover": { color: "text.primary" },
|
|
37
|
+
...active ? {
|
|
38
|
+
color: "primary.main",
|
|
39
|
+
bgcolor: "background.paper",
|
|
40
|
+
borderColor: "divider",
|
|
41
|
+
boxShadow: theme.rt?.lift?.rest ?? theme.shadows[1],
|
|
42
|
+
...theme.applyStyles?.("dark", { boxShadow: theme.rt?.lift?.restDark ?? theme.shadows[1] }),
|
|
43
|
+
"&:hover": { color: "primary.main" }
|
|
44
|
+
} : null,
|
|
45
|
+
"@media (prefers-reduced-motion: reduce)": { transition: "none" }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function underlineRowSx() {
|
|
49
|
+
return { display: "flex", alignItems: "stretch", columnGap: 3 };
|
|
50
|
+
}
|
|
51
|
+
function underlineTabSx(active) {
|
|
52
|
+
return {
|
|
53
|
+
display: "inline-flex",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
gap: 0.5,
|
|
56
|
+
py: 1.5,
|
|
57
|
+
whiteSpace: "nowrap",
|
|
58
|
+
textDecoration: "none",
|
|
59
|
+
fontSize: "0.875rem",
|
|
60
|
+
fontWeight: 500,
|
|
61
|
+
color: active ? "secondary.main" : "text.secondary",
|
|
62
|
+
borderBottom: "2px solid",
|
|
63
|
+
borderColor: active ? "secondary.main" : "transparent",
|
|
64
|
+
mb: "-1px",
|
|
65
|
+
// sit the indicator on the bar's own bottom border
|
|
66
|
+
cursor: "pointer",
|
|
67
|
+
transition: "color 150ms ease, border-color 150ms ease",
|
|
68
|
+
"&:hover": { color: active ? "secondary.main" : "text.primary" },
|
|
69
|
+
"@media (prefers-reduced-motion: reduce)": { transition: "none" }
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
WELL_PAD,
|
|
74
|
+
underlineRowSx,
|
|
75
|
+
underlineTabSx,
|
|
76
|
+
wellSx,
|
|
77
|
+
wellTabSx
|
|
78
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2
|
+
const MONTHS_LONG = [
|
|
3
|
+
"January",
|
|
4
|
+
"February",
|
|
5
|
+
"March",
|
|
6
|
+
"April",
|
|
7
|
+
"May",
|
|
8
|
+
"June",
|
|
9
|
+
"July",
|
|
10
|
+
"August",
|
|
11
|
+
"September",
|
|
12
|
+
"October",
|
|
13
|
+
"November",
|
|
14
|
+
"December"
|
|
15
|
+
];
|
|
16
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
17
|
+
const DEFAULT_DATE_FORMAT = "medium";
|
|
18
|
+
const DEFAULT_TIME_FORMAT = "24h";
|
|
19
|
+
const RENDERERS = {
|
|
20
|
+
medium: (p) => `${p.d} ${MONTHS_SHORT[p.m - 1]} ${p.y}`,
|
|
21
|
+
medium_us: (p) => `${MONTHS_SHORT[p.m - 1]} ${p.d}, ${p.y}`,
|
|
22
|
+
long: (p) => `${p.d} ${MONTHS_LONG[p.m - 1]} ${p.y}`,
|
|
23
|
+
dmy: (p) => `${pad(p.d)}/${pad(p.m)}/${p.y}`,
|
|
24
|
+
mdy: (p) => `${pad(p.m)}/${pad(p.d)}/${p.y}`,
|
|
25
|
+
iso: (p) => `${p.y}-${pad(p.m)}-${pad(p.d)}`
|
|
26
|
+
};
|
|
27
|
+
const DATE_FORMATS = [
|
|
28
|
+
{ key: "medium", label: "Day Month Year" },
|
|
29
|
+
{ key: "medium_us", label: "Month Day, Year" },
|
|
30
|
+
{ key: "long", label: "Day Month Year (full)" },
|
|
31
|
+
{ key: "dmy", label: "Numeric, day first" },
|
|
32
|
+
{ key: "mdy", label: "Numeric, month first" },
|
|
33
|
+
{ key: "iso", label: "ISO 8601" }
|
|
34
|
+
].map((f) => ({ ...f, example: RENDERERS[f.key]({ y: 2026, m: 9, d: 30 }) }));
|
|
35
|
+
const CLOCKS = { "24h": "24h", "12h": "12h" };
|
|
36
|
+
const TIME_FORMATS = [
|
|
37
|
+
{ key: "24h", label: "24-hour", clock: "24h", zone: null },
|
|
38
|
+
{ key: "12h", label: "12-hour", clock: "12h", zone: null },
|
|
39
|
+
{ key: "24h_zone", label: "24-hour with timezone", clock: "24h", zone: "abbr" },
|
|
40
|
+
{ key: "12h_zone", label: "12-hour with timezone", clock: "12h", zone: "abbr" },
|
|
41
|
+
{ key: "24h_offset", label: "24-hour with UTC offset", clock: "24h", zone: "offset" },
|
|
42
|
+
{ key: "12h_offset", label: "12-hour with UTC offset", clock: "12h", zone: "offset" },
|
|
43
|
+
{ key: "24h_zone_offset", label: "24-hour with timezone and UTC offset", clock: "24h", zone: "both" },
|
|
44
|
+
{ key: "12h_zone_offset", label: "12-hour with timezone and UTC offset", clock: "12h", zone: "both" }
|
|
45
|
+
];
|
|
46
|
+
const timeSpec = (key) => TIME_FORMATS.find((f) => f.key === key) ?? TIME_FORMATS[0];
|
|
47
|
+
function timeFormatOptions(timezone, sample = "2026-09-30T17:00") {
|
|
48
|
+
return TIME_FORMATS.map((f) => ({
|
|
49
|
+
...f,
|
|
50
|
+
example: formatOrgTime(sample, { timezone, time_format: f.key })
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
function partsOf(value, tz) {
|
|
54
|
+
const raw = String(value ?? "");
|
|
55
|
+
if (raw === "") return null;
|
|
56
|
+
const local = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(raw);
|
|
57
|
+
if (local) {
|
|
58
|
+
return {
|
|
59
|
+
y: Number(local[1]),
|
|
60
|
+
m: Number(local[2]),
|
|
61
|
+
d: Number(local[3]),
|
|
62
|
+
hh: local[4] === void 0 ? 0 : Number(local[4]),
|
|
63
|
+
mm: local[5] === void 0 ? 0 : Number(local[5]),
|
|
64
|
+
hasTime: local[4] !== void 0,
|
|
65
|
+
// No instant yet: a wall-clock string names a time in the org's zone, and
|
|
66
|
+
// which instant that is depends on the zone's offset on that very date.
|
|
67
|
+
// Resolved on demand in instantOf, only when a zone marker is wanted.
|
|
68
|
+
instant: null
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const dt = new Date(raw);
|
|
72
|
+
if (Number.isNaN(dt.getTime())) return null;
|
|
73
|
+
let f;
|
|
74
|
+
try {
|
|
75
|
+
f = new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
timeZone: tz || "UTC",
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
hour12: false
|
|
83
|
+
}).formatToParts(dt);
|
|
84
|
+
} catch {
|
|
85
|
+
return partsOf(raw, "UTC");
|
|
86
|
+
}
|
|
87
|
+
const p = Object.fromEntries(f.map((x) => [x.type, x.value]));
|
|
88
|
+
return {
|
|
89
|
+
y: Number(p.year),
|
|
90
|
+
m: Number(p.month),
|
|
91
|
+
d: Number(p.day),
|
|
92
|
+
// en-CA renders midnight as "24" rather than "00" in some engines.
|
|
93
|
+
hh: Number(p.hour) % 24,
|
|
94
|
+
mm: Number(p.minute),
|
|
95
|
+
hasTime: true,
|
|
96
|
+
instant: dt
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function offsetMinutesAt(date, tz) {
|
|
100
|
+
const f = new Intl.DateTimeFormat("en-CA", {
|
|
101
|
+
timeZone: tz,
|
|
102
|
+
year: "numeric",
|
|
103
|
+
month: "2-digit",
|
|
104
|
+
day: "2-digit",
|
|
105
|
+
hour: "2-digit",
|
|
106
|
+
minute: "2-digit",
|
|
107
|
+
second: "2-digit",
|
|
108
|
+
hour12: false
|
|
109
|
+
}).formatToParts(date);
|
|
110
|
+
const p = Object.fromEntries(f.map((x) => [x.type, x.value]));
|
|
111
|
+
const asUtc = Date.UTC(
|
|
112
|
+
Number(p.year),
|
|
113
|
+
Number(p.month) - 1,
|
|
114
|
+
Number(p.day),
|
|
115
|
+
Number(p.hour) % 24,
|
|
116
|
+
Number(p.minute),
|
|
117
|
+
Number(p.second)
|
|
118
|
+
);
|
|
119
|
+
return (asUtc - date.getTime()) / 6e4;
|
|
120
|
+
}
|
|
121
|
+
function instantOf(p, tz) {
|
|
122
|
+
if (p.instant) return p.instant;
|
|
123
|
+
const guess = Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm);
|
|
124
|
+
const first = offsetMinutesAt(new Date(guess), tz);
|
|
125
|
+
const corrected = guess - first * 6e4;
|
|
126
|
+
return new Date(guess - offsetMinutesAt(new Date(corrected), tz) * 6e4);
|
|
127
|
+
}
|
|
128
|
+
function zoneMarker(p, tz, spec) {
|
|
129
|
+
if (!spec.zone) return "";
|
|
130
|
+
const zone = tz || "UTC";
|
|
131
|
+
const named = (style) => new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style, year: "numeric" }).formatToParts(instantOf(p, zone)).find((x) => x.type === "timeZoneName")?.value;
|
|
132
|
+
try {
|
|
133
|
+
if (spec.zone === "abbr") return named("short") ?? "";
|
|
134
|
+
const offset = named("longOffset");
|
|
135
|
+
if (!offset) return "";
|
|
136
|
+
const utc = `(UTC${normalizeOffset(offset)})`;
|
|
137
|
+
if (spec.zone === "offset") return utc;
|
|
138
|
+
const abbr = named("short");
|
|
139
|
+
return abbr ? `${abbr} ${utc}` : utc;
|
|
140
|
+
} catch {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function clock(p, spec) {
|
|
145
|
+
if (CLOCKS[spec.clock] === "12h") {
|
|
146
|
+
const h = p.hh % 12 === 0 ? 12 : p.hh % 12;
|
|
147
|
+
return `${h}:${pad(p.mm)} ${p.hh < 12 ? "AM" : "PM"}`;
|
|
148
|
+
}
|
|
149
|
+
return `${pad(p.hh)}:${pad(p.mm)}`;
|
|
150
|
+
}
|
|
151
|
+
function formatOrgTime(value, org = {}, { zone = true } = {}) {
|
|
152
|
+
const p = partsOf(value, org.timezone);
|
|
153
|
+
if (!p) return null;
|
|
154
|
+
const spec = timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT);
|
|
155
|
+
const marker = zone ? zoneMarker(p, org.timezone, spec) : null;
|
|
156
|
+
return marker ? `${clock(p, spec)} ${marker}` : clock(p, spec);
|
|
157
|
+
}
|
|
158
|
+
function formatOrgDate(value, org = {}, { withTime = "auto" } = {}) {
|
|
159
|
+
const p = partsOf(value, org.timezone);
|
|
160
|
+
if (!p) return null;
|
|
161
|
+
const render = RENDERERS[org.date_format] ?? RENDERERS[DEFAULT_DATE_FORMAT];
|
|
162
|
+
const date = render(p);
|
|
163
|
+
const show = withTime === "auto" ? p.hasTime && !(p.hh === 0 && p.mm === 0) : withTime;
|
|
164
|
+
if (!show) return date;
|
|
165
|
+
return `${date}, ${formatOrgTime(value, org)}`;
|
|
166
|
+
}
|
|
167
|
+
function formatOrgDateTimeShort(value, org = {}) {
|
|
168
|
+
return formatOrgDate(value, org, { withTime: true });
|
|
169
|
+
}
|
|
170
|
+
function orgDay(value, org = {}) {
|
|
171
|
+
const p = partsOf(value, org.timezone);
|
|
172
|
+
if (!p) return null;
|
|
173
|
+
return `${String(p.y).padStart(4, "0")}-${String(p.m).padStart(2, "0")}-${String(p.d).padStart(2, "0")}`;
|
|
174
|
+
}
|
|
175
|
+
function normalizeOffset(name) {
|
|
176
|
+
const raw = String(name || "").replace(/^GMT/, "").trim();
|
|
177
|
+
if (raw === "") return "+00:00";
|
|
178
|
+
const m = raw.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
|
|
179
|
+
return m ? `${m[1]}${m[2].padStart(2, "0")}:${m[3] || "00"}` : "+00:00";
|
|
180
|
+
}
|
|
181
|
+
function formatOrgDateTime(iso, org = {}) {
|
|
182
|
+
const p = partsOf(iso, org.timezone);
|
|
183
|
+
if (!p) return null;
|
|
184
|
+
const render = RENDERERS[org.date_format] ?? RENDERERS[DEFAULT_DATE_FORMAT];
|
|
185
|
+
const stamp = `${render(p)}, ${formatOrgTime(iso, org)}`;
|
|
186
|
+
if (timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT).zone) return stamp;
|
|
187
|
+
const zone = zoneSuffix(iso, org.timezone);
|
|
188
|
+
return zone ? `${stamp} ${zone}` : stamp;
|
|
189
|
+
}
|
|
190
|
+
function orgZoneMark(value, org = {}) {
|
|
191
|
+
if (timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT).zone) return "";
|
|
192
|
+
if (!partsOf(value, org.timezone)?.hasTime) return "";
|
|
193
|
+
return zoneSuffix(value, org.timezone);
|
|
194
|
+
}
|
|
195
|
+
function zoneSuffix(value, tz) {
|
|
196
|
+
const d = new Date(String(value ?? ""));
|
|
197
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
198
|
+
try {
|
|
199
|
+
const zone = tz || "UTC";
|
|
200
|
+
const short = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortGeneric", year: "numeric" }).formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
|
201
|
+
const long = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset", year: "numeric" }).formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
|
202
|
+
return `${short} (GMT/UTC ${normalizeOffset(long)})`;
|
|
203
|
+
} catch {
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function timeLeftToSubmit(endIso) {
|
|
208
|
+
const left = timeLeftParts(endIso);
|
|
209
|
+
if (!left) return null;
|
|
210
|
+
return `${left.count} ${left.unit}${left.count === 1 ? "" : "s"} left to submit`;
|
|
211
|
+
}
|
|
212
|
+
function timeLeftParts(endIso) {
|
|
213
|
+
if (!endIso) return null;
|
|
214
|
+
const end = new Date(endIso);
|
|
215
|
+
if (Number.isNaN(end.getTime())) return null;
|
|
216
|
+
const ms = end.getTime() - Date.now();
|
|
217
|
+
if (ms <= 0) return null;
|
|
218
|
+
const mins = Math.floor(ms / 6e4);
|
|
219
|
+
const days = Math.floor(mins / 1440);
|
|
220
|
+
const hours = Math.floor(mins % 1440 / 60);
|
|
221
|
+
if (days >= 1) return { count: days, unit: "day" };
|
|
222
|
+
if (hours >= 1) return { count: hours, unit: "hour" };
|
|
223
|
+
return { count: Math.max(1, mins), unit: "minute" };
|
|
224
|
+
}
|
|
225
|
+
function timeLeftKey(unit) {
|
|
226
|
+
return { day: "timeLeftDays", hour: "timeLeftHours", minute: "timeLeftMinutes" }[unit];
|
|
227
|
+
}
|
|
228
|
+
export {
|
|
229
|
+
DATE_FORMATS,
|
|
230
|
+
DEFAULT_DATE_FORMAT,
|
|
231
|
+
DEFAULT_TIME_FORMAT,
|
|
232
|
+
TIME_FORMATS,
|
|
233
|
+
formatOrgDate,
|
|
234
|
+
formatOrgDateTime,
|
|
235
|
+
formatOrgDateTimeShort,
|
|
236
|
+
formatOrgTime,
|
|
237
|
+
orgDay,
|
|
238
|
+
orgZoneMark,
|
|
239
|
+
timeFormatOptions,
|
|
240
|
+
timeLeftKey,
|
|
241
|
+
timeLeftParts,
|
|
242
|
+
timeLeftToSubmit
|
|
243
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
|
|
2
|
+
const SAFE_STYLES = {
|
|
3
|
+
color: [/^#(?:[0-9a-fA-F]{3,4}){1,2}$/, /^rgba?\([\d.,%\s]+\)$/, /^[a-z]+$/i],
|
|
4
|
+
"background-color": [/^#(?:[0-9a-fA-F]{3,4}){1,2}$/, /^rgba?\([\d.,%\s]+\)$/, /^[a-z]+$/i],
|
|
5
|
+
"text-align": [/^(?:left|right|center|justify)$/],
|
|
6
|
+
"font-weight": [/^(?:normal|bold|[1-9]00)$/],
|
|
7
|
+
"font-style": [/^(?:normal|italic|oblique)$/],
|
|
8
|
+
"text-decoration": [/^(?:none|underline|line-through|overline)$/]
|
|
9
|
+
};
|
|
10
|
+
const PAGE_SANITIZE_OPTIONS = {
|
|
11
|
+
allowedTags: [
|
|
12
|
+
...HEADINGS,
|
|
13
|
+
"p",
|
|
14
|
+
"br",
|
|
15
|
+
"hr",
|
|
16
|
+
"blockquote",
|
|
17
|
+
"pre",
|
|
18
|
+
"code",
|
|
19
|
+
"strong",
|
|
20
|
+
"em",
|
|
21
|
+
"b",
|
|
22
|
+
"i",
|
|
23
|
+
"u",
|
|
24
|
+
"s",
|
|
25
|
+
"sub",
|
|
26
|
+
"sup",
|
|
27
|
+
"span",
|
|
28
|
+
"ul",
|
|
29
|
+
"ol",
|
|
30
|
+
"li",
|
|
31
|
+
"a",
|
|
32
|
+
"img",
|
|
33
|
+
"figure",
|
|
34
|
+
"figcaption",
|
|
35
|
+
// Tables, including the parts a real table needs: a caption, a footer, and
|
|
36
|
+
// column groups.
|
|
37
|
+
//
|
|
38
|
+
// colgroup/col are kept for STRUCTURE, not for widths — an earlier comment
|
|
39
|
+
// here claimed the opposite. Column widths never survive: `width` is not in
|
|
40
|
+
// SAFE_STYLES, so a <col style="width:220px"> arrives as a bare <col />.
|
|
41
|
+
// The tags stay because dropping them would leave orphaned <col> elements
|
|
42
|
+
// for the parser to relocate inside the table, rearranging markup an author
|
|
43
|
+
// wrote by hand.
|
|
44
|
+
"table",
|
|
45
|
+
"thead",
|
|
46
|
+
"tbody",
|
|
47
|
+
"tfoot",
|
|
48
|
+
"caption",
|
|
49
|
+
"colgroup",
|
|
50
|
+
"col",
|
|
51
|
+
"tr",
|
|
52
|
+
"th",
|
|
53
|
+
"td"
|
|
54
|
+
],
|
|
55
|
+
allowedAttributes: {
|
|
56
|
+
a: ["href", "title", "target", "rel"],
|
|
57
|
+
img: ["src", "alt", "title", "width", "height"],
|
|
58
|
+
// Alignment is emitted as an inline style on the BLOCK, not on a span, so
|
|
59
|
+
// the tags a block can be need to carry `style` — constrained to
|
|
60
|
+
// SAFE_STYLES above, which is what keeps that widening harmless.
|
|
61
|
+
span: ["style"],
|
|
62
|
+
p: ["style"],
|
|
63
|
+
...Object.fromEntries(HEADINGS.map((h) => [h, ["style"]])),
|
|
64
|
+
// colspan/rowspan are how merged cells survive; without them a merged table
|
|
65
|
+
// silently reflows into the wrong shape.
|
|
66
|
+
td: ["style", "colspan", "rowspan"],
|
|
67
|
+
th: ["style", "colspan", "rowspan", "scope"],
|
|
68
|
+
col: ["span", "style"],
|
|
69
|
+
"*": ["class"]
|
|
70
|
+
},
|
|
71
|
+
allowedStyles: { "*": SAFE_STYLES },
|
|
72
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
73
|
+
/*
|
|
74
|
+
* Images are SAME-ORIGIN ONLY: no scheme is allowed for <img>, which leaves
|
|
75
|
+
* relative sources — e.g. `/images/{key}`, served from the site's own domain
|
|
76
|
+
* by a route handler.
|
|
77
|
+
*
|
|
78
|
+
* Refusing remote images is what lets a public page's `img-src` close to
|
|
79
|
+
* 'self' data:. A page that renders administrator-authored HTML may share a
|
|
80
|
+
* registrable parent domain with an authenticated app, so an <img> pointing
|
|
81
|
+
* anywhere it likes is an exfiltration channel for anything that survives
|
|
82
|
+
* this sanitiser. Hotlinking is also the worse feature: the image rots when
|
|
83
|
+
* the far end moves it, and it leaks every reader's IP to a third party.
|
|
84
|
+
*
|
|
85
|
+
* `data:` stays for <img> alone — the editor can produce one while an upload
|
|
86
|
+
* is in flight, and a data: URL on any OTHER tag is a script-execution vector.
|
|
87
|
+
*/
|
|
88
|
+
allowedSchemesByTag: { img: ["data"] },
|
|
89
|
+
// `//evil.test/x.png` has no scheme, so a scheme allow-list does not stop it —
|
|
90
|
+
// the browser resolves it against the page's protocol and fetches it. Without
|
|
91
|
+
// this, every restriction above is one slash away from being bypassed.
|
|
92
|
+
allowProtocolRelative: false,
|
|
93
|
+
transformTags: {
|
|
94
|
+
// Any link that opens a new tab must not hand the opener over.
|
|
95
|
+
a: (tagName, attribs) => ({
|
|
96
|
+
tagName,
|
|
97
|
+
attribs: { ...attribs, ...attribs.target === "_blank" ? { rel: "noopener noreferrer" } : {} }
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const NO_IMAGES = ["img", "figure", "figcaption"];
|
|
102
|
+
const EMAIL_TABLE_ATTRS = {
|
|
103
|
+
table: ["align", "width", "border", "cellpadding", "cellspacing", "bgcolor"],
|
|
104
|
+
thead: ["align", "bgcolor"],
|
|
105
|
+
tbody: ["align", "bgcolor"],
|
|
106
|
+
tfoot: ["align", "bgcolor"],
|
|
107
|
+
tr: ["align", "valign", "bgcolor"],
|
|
108
|
+
td: ["align", "valign", "bgcolor", "width", "height", "nowrap"],
|
|
109
|
+
th: ["align", "valign", "bgcolor", "width", "height", "nowrap"],
|
|
110
|
+
col: ["align", "valign", "width"]
|
|
111
|
+
};
|
|
112
|
+
const EMAIL_SANITIZE_OPTIONS = {
|
|
113
|
+
...PAGE_SANITIZE_OPTIONS,
|
|
114
|
+
allowedTags: PAGE_SANITIZE_OPTIONS.allowedTags.filter((t) => !NO_IMAGES.includes(t)),
|
|
115
|
+
/*
|
|
116
|
+
* Derived by ADDITION, the mirror of the subtraction above: each widened tag
|
|
117
|
+
* is the PAGE list plus the email-only extras, so a tag that gains an
|
|
118
|
+
* attribute on the page side gains it here automatically. Restating the lists
|
|
119
|
+
* would be the second copy this file's opening comment warns about.
|
|
120
|
+
*/
|
|
121
|
+
allowedAttributes: {
|
|
122
|
+
...PAGE_SANITIZE_OPTIONS.allowedAttributes,
|
|
123
|
+
...Object.fromEntries(
|
|
124
|
+
Object.entries(EMAIL_TABLE_ATTRS).map(([tag, extra]) => [
|
|
125
|
+
tag,
|
|
126
|
+
[...PAGE_SANITIZE_OPTIONS.allowedAttributes[tag] ?? [], ...extra]
|
|
127
|
+
])
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const GUIDELINES_SANITIZE_OPTIONS = {
|
|
132
|
+
...PAGE_SANITIZE_OPTIONS,
|
|
133
|
+
allowedTags: PAGE_SANITIZE_OPTIONS.allowedTags.filter((t) => !NO_IMAGES.includes(t))
|
|
134
|
+
};
|
|
135
|
+
export {
|
|
136
|
+
EMAIL_SANITIZE_OPTIONS,
|
|
137
|
+
GUIDELINES_SANITIZE_OPTIONS,
|
|
138
|
+
PAGE_SANITIZE_OPTIONS
|
|
139
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function safeNext(next, fallback = "/", prefix = null) {
|
|
2
|
+
if (typeof next !== "string" || next === "") return fallback;
|
|
3
|
+
if (!next.startsWith("/") || next[1] === "/" || next[1] === "\\") return fallback;
|
|
4
|
+
if (/^\/[^/]*:/.test(next)) return fallback;
|
|
5
|
+
if (prefix !== null) {
|
|
6
|
+
const inside = next === prefix || next.startsWith(`${prefix}/`) || next.startsWith(`${prefix}?`) || next.startsWith(`${prefix}#`);
|
|
7
|
+
if (!inside) return fallback;
|
|
8
|
+
}
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
safeNext as default
|
|
13
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
import { GUIDELINES_SANITIZE_OPTIONS } from "./pageHtml.js";
|
|
3
|
+
const INLINE_TAGS = ["strong", "b", "em", "i", "u", "a", "br"];
|
|
4
|
+
const BLOCK_TAGS = ["p", ...INLINE_TAGS];
|
|
5
|
+
const RICH_TAGS = GUIDELINES_SANITIZE_OPTIONS.allowedTags;
|
|
6
|
+
const RICH_ATTR = [
|
|
7
|
+
...new Set(Object.values(GUIDELINES_SANITIZE_OPTIONS.allowedAttributes).flat())
|
|
8
|
+
].filter((attr) => attr !== "style");
|
|
9
|
+
let hooked = false;
|
|
10
|
+
function ensureLinkHook() {
|
|
11
|
+
if (hooked || typeof window === "undefined" || typeof DOMPurify.addHook !== "function") return;
|
|
12
|
+
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
13
|
+
if (node.tagName === "A") {
|
|
14
|
+
node.setAttribute("target", "_blank");
|
|
15
|
+
node.setAttribute("rel", "noopener noreferrer nofollow");
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
hooked = true;
|
|
19
|
+
}
|
|
20
|
+
function sanitizeHtml(html, { block = false, rich = false } = {}) {
|
|
21
|
+
if (!html) return "";
|
|
22
|
+
if (typeof window === "undefined") return "";
|
|
23
|
+
ensureLinkHook();
|
|
24
|
+
let input = String(html);
|
|
25
|
+
if (!block && !rich) {
|
|
26
|
+
input = input.replace(/<\/p\s*>\s*<p\b[^>]*>/gi, "<br>").replace(/<\/?p\b[^>]*>/gi, "");
|
|
27
|
+
}
|
|
28
|
+
return DOMPurify.sanitize(input, {
|
|
29
|
+
ALLOWED_TAGS: rich ? RICH_TAGS : block ? BLOCK_TAGS : INLINE_TAGS,
|
|
30
|
+
ALLOWED_ATTR: rich ? RICH_ATTR : ["href", "target", "rel"],
|
|
31
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/)/i
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
sanitizeHtml
|
|
36
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
function slugify(name) {
|
|
2
|
+
const slug = String(name ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/g, "");
|
|
3
|
+
return slug.length >= 3 ? slug : "";
|
|
4
|
+
}
|
|
5
|
+
export {
|
|
6
|
+
slugify as default
|
|
7
|
+
};
|