portal-mojo 0.2.8 → 0.2.9

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.
Files changed (149) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +6 -2
  4. package/docs/account.md +4 -3
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/calendar.md +1 -1
  22. package/docs/client.md +159 -26
  23. package/docs/collection-multiselect.md +2 -2
  24. package/docs/combobox.md +2 -2
  25. package/docs/dataview.md +1 -1
  26. package/docs/datepicker.md +3 -3
  27. package/docs/daterange.md +1 -1
  28. package/docs/datetimepicker.md +1 -1
  29. package/docs/defineModel.md +40 -7
  30. package/docs/detail-primitives.md +1 -1
  31. package/docs/feedback.md +9 -8
  32. package/docs/file-upload-ux.md +2 -2
  33. package/docs/idioms.md +1 -1
  34. package/docs/loading.md +2 -2
  35. package/docs/location-address.md +2 -1
  36. package/docs/markdown.md +3 -3
  37. package/docs/menus-and-access.md +21 -1
  38. package/docs/multiselect-dropdown.md +1 -1
  39. package/docs/popover.md +1 -1
  40. package/docs/record-feed.md +2 -2
  41. package/docs/taginput.md +2 -1
  42. package/docs/timepicker.md +1 -1
  43. package/docs/timezone-select.md +4 -3
  44. package/docs/user-menu.md +4 -3
  45. package/docs/worldmap.md +2 -2
  46. package/package.json +3 -2
  47. package/src/account/AccountModal.tsx +3 -2
  48. package/src/account/index.ts +2 -2
  49. package/src/admin/core/index.ts +1 -1
  50. package/src/admin/dns/models.ts +1 -1
  51. package/src/admin/domains/infrastructure.ts +12 -2
  52. package/src/admin/domains/security.ts +3 -1
  53. package/src/admin/edge/BlocklistPage.tsx +110 -0
  54. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  55. package/src/admin/edge/VhostDetail.tsx +166 -0
  56. package/src/admin/edge/VhostWizard.tsx +273 -0
  57. package/src/admin/edge/VhostsPage.tsx +47 -0
  58. package/src/admin/edge/api.ts +56 -0
  59. package/src/admin/edge/index.ts +15 -0
  60. package/src/admin/edge/models.ts +395 -0
  61. package/src/admin/edge/permissions.ts +13 -0
  62. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  63. package/src/admin/identity/users/sections/actions.tsx +3 -3
  64. package/src/admin/index.ts +1 -0
  65. package/src/admin/phonehub/api.ts +5 -3
  66. package/src/admin/public/infrastructure.ts +1 -0
  67. package/src/admin/registry.ts +2 -1
  68. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  69. package/src/admin/storage/file-renditions.ts +12 -0
  70. package/src/admin/storage/index.ts +2 -0
  71. package/src/admin/storage/rendition-settings.ts +264 -0
  72. package/src/charts/exportChart.ts +1 -1
  73. package/src/client/action-result.ts +23 -61
  74. package/src/client/active-group.ts +9 -3
  75. package/src/client/auth.ts +18 -4
  76. package/src/client/client.ts +49 -10
  77. package/src/client/errors.ts +57 -2
  78. package/src/client/group-context.ts +2 -0
  79. package/src/client/group.tsx +112 -20
  80. package/src/client/hooks.ts +7 -3
  81. package/src/client/mock.ts +958 -14
  82. package/src/client/model.ts +15 -3
  83. package/src/client/renditions-mock.ts +212 -0
  84. package/src/client/scoped.ts +19 -5
  85. package/src/styles/components/account.css +304 -0
  86. package/src/styles/components/admin-assistant.css +28 -0
  87. package/src/styles/components/admin-bouncer.css +53 -0
  88. package/src/styles/components/admin-cloudwatch.css +23 -0
  89. package/src/styles/components/admin-dashboard.css +16 -0
  90. package/src/styles/components/admin-devices.css +260 -0
  91. package/src/styles/components/admin-dns-certificates.css +28 -0
  92. package/src/styles/components/admin-dns-registrar.css +1 -0
  93. package/src/styles/components/admin-dns.css +43 -0
  94. package/src/styles/components/admin-edge.css +1 -0
  95. package/src/styles/components/admin-incidents.css +14 -0
  96. package/src/styles/components/admin-jobs.css +237 -0
  97. package/src/styles/components/admin-media-rendering.css +38 -0
  98. package/src/styles/components/admin-messaging.css +3 -0
  99. package/src/styles/components/admin-monitoring.css +152 -0
  100. package/src/styles/components/admin-network.css +406 -0
  101. package/src/styles/components/admin-phonehub.css +61 -0
  102. package/src/styles/components/admin-push.css +30 -0
  103. package/src/styles/components/admin-rules.css +23 -0
  104. package/src/styles/components/admin-security.css +37 -0
  105. package/src/styles/components/admin-shortlinks.css +8 -0
  106. package/src/styles/components/admin-signin.css +64 -0
  107. package/src/styles/components/admin-storage.css +4 -0
  108. package/src/styles/components/auth.css +162 -0
  109. package/src/styles/components/calendar.css +228 -0
  110. package/src/styles/components/charts-c2.css +249 -0
  111. package/src/styles/components/collection-multiselect.css +114 -0
  112. package/src/styles/components/collection-select.css +63 -0
  113. package/src/styles/components/combobox.css +42 -0
  114. package/src/styles/components/dataview.css +139 -0
  115. package/src/styles/components/datepicker.css +106 -0
  116. package/src/styles/components/daterange.css +119 -0
  117. package/src/styles/components/datetimepicker.css +230 -0
  118. package/src/styles/components/detail-primitives.css +246 -0
  119. package/src/styles/components/detailview.css +69 -0
  120. package/src/styles/components/drawer.css +111 -0
  121. package/src/styles/components/file-upload.css +112 -0
  122. package/src/styles/components/form-wizard.css +11 -0
  123. package/src/styles/components/formview.css +74 -0
  124. package/src/styles/components/group-admin.css +302 -0
  125. package/src/styles/components/guardrail.css +29 -0
  126. package/src/styles/components/idioms.css +84 -0
  127. package/src/styles/components/image-editor.css +80 -0
  128. package/src/styles/components/loading.css +200 -0
  129. package/src/styles/components/location-address.css +32 -0
  130. package/src/styles/components/markdown.css +192 -0
  131. package/src/styles/components/multiselect-dropdown.css +92 -0
  132. package/src/styles/components/popover.css +35 -0
  133. package/src/styles/components/record-feed.css +161 -0
  134. package/src/styles/components/right-panel.css +37 -0
  135. package/src/styles/components/tabs.css +26 -0
  136. package/src/styles/components/taginput.css +132 -0
  137. package/src/styles/components/timepicker.css +282 -0
  138. package/src/styles/components/user-admin.css +189 -0
  139. package/src/styles/components/user-menu.css +98 -0
  140. package/src/styles/components/worldmap.css +97 -0
  141. package/src/styles/core.css +807 -0
  142. package/src/styles/index.css +69 -0
  143. package/src/styles/required-tokens.json +32 -0
  144. package/src/ui/FormWizard.tsx +9 -3
  145. package/src/ui/ThemeProvider.tsx +1 -1
  146. package/src/ui/detail/index.ts +2 -2
  147. package/src/ui/index.ts +3 -2
  148. package/src/ui/loading.tsx +1 -1
  149. package/src/ui/toast.tsx +1 -1
@@ -0,0 +1,258 @@
1
+ // Storage → Media rendering (#7729): edit the three FILEMAN_RENDITIONS_*
2
+ // settings as a form built from django-mojo's rendition descriptor. See
3
+ // docs/admin-media-rendering.md for the contract and the invariants.
4
+ import { useMemo, useState } from 'react';
5
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { Badge, Tabs, modal, toast } from '../../ui';
7
+ import type { SettingRow } from '../settings/model';
8
+ import { renditionRoleLabel } from './file-renditions';
9
+ import {
10
+ RENDITION_OPTIONS_ENDPOINT, AUTOMATIC_KEY, MP4_ONLY_OPTIONS, coerceFieldValue, draftToValue, fieldType, findCategoryRows,
11
+ hidesMp4Options, isDefaultValue, orderedCategories, readRenditionOptions, renditionCategoryLabel, resetCategory,
12
+ routeRenditionError, sameOverride, sameValue, saveCategoryValue, validateDraft, valueToDraft,
13
+ type CategoryDraft, type RenditionCategory, type RenditionOptions,
14
+ } from './rendition-settings';
15
+
16
+ const OPTIONS_QUERY_KEY = ['fileman', 'renditions', 'options'] as const;
17
+ const rowQueryKey = (key: string) => ['settings', 'rendition-row', key] as const;
18
+
19
+ const FIELD_LABELS: Record<string, string> = {
20
+ width: 'Width', height: 'Height', mode: 'Mode', format: 'Format', quality: 'Quality', time_offset: 'Frame at',
21
+ bitrate: 'Bitrate', codec: 'Codec', crf: 'CRF', preset: 'Preset', duration: 'Seconds', audio: 'Audio', page: 'Page', max_pages: 'Max pages',
22
+ };
23
+ const PLACEHOLDERS: Record<string, string> = { bitrate: '2000k', time_offset: 'HH:MM:SS' };
24
+ // Display order only — membership always comes from the descriptor's `fields[kind]` (served sorted).
25
+ const FIELD_ORDER = ['width', 'height', 'mode', 'format', 'quality', 'time_offset', 'page', 'max_pages', 'bitrate', 'codec', 'crf', 'preset', 'duration', 'audio'];
26
+
27
+ function orderFields(names: readonly string[]): string[] {
28
+ const rank = (name: string) => { const at = FIELD_ORDER.indexOf(name); return at === -1 ? FIELD_ORDER.length : at; };
29
+ return [...names].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
30
+ }
31
+
32
+ function fieldLabel(name: string): string {
33
+ return FIELD_LABELS[name] ?? name.replace(/_/g, ' ');
34
+ }
35
+
36
+ function displayValue(value: unknown): string {
37
+ if (value === null || value === undefined || value === '') return 'not set';
38
+ return typeof value === 'boolean' ? (value ? 'on' : 'off') : String(value);
39
+ }
40
+
41
+ interface FieldProps {
42
+ category: RenditionCategory;
43
+ draft: CategoryDraft;
44
+ role: string;
45
+ kind: string;
46
+ name: string;
47
+ error: string | undefined;
48
+ disabled: boolean;
49
+ onChange: (name: string, value: unknown) => void;
50
+ }
51
+
52
+ function RoleField({ category, draft, role, kind, name, error, disabled, onChange }: FieldProps) {
53
+ const type = fieldType(category, kind, name);
54
+ const value = draft.roles[role]?.[name];
55
+ const fallback = category.defaults[role]?.[name];
56
+ const effective = category.effective.roles[role]?.[name];
57
+ const isDefault = isDefaultValue(category, draft, role, name);
58
+ const inEffect = !sameValue(effective, value) && effective !== undefined ? effective : undefined;
59
+ const id = `media-rendering-${category.key}-${role}-${name}`;
60
+ const className = `media-rendering-field${error ? ' is-invalid' : isDefault ? ' is-default' : ' is-changed'}`;
61
+ const title = `Default: ${displayValue(fallback)}`;
62
+ const help = error ?? (inEffect !== undefined ? `In effect: ${displayValue(inEffect)} (deployment file)` : undefined);
63
+ let control;
64
+ if (type === 'boolean') {
65
+ control = <span className="media-rendering-field-switch">
66
+ <input id={id} type="checkbox" role="switch" className="switch" checked={value === true} disabled={disabled} title={title} onChange={(event) => onChange(name, event.target.checked)} />
67
+ <span className="dim">{value === true ? 'on' : 'off'}</span>
68
+ </span>;
69
+ } else if (type === 'choice') {
70
+ const choices = category.choices[kind]?.[name] ?? [];
71
+ const current = typeof value === 'string' ? value : '';
72
+ control = <select id={id} className="input" value={current} disabled={disabled} title={title} onChange={(event) => onChange(name, event.target.value)}>
73
+ <option value="">{fallback === undefined ? 'renderer default' : 'not set'}</option>
74
+ {!choices.includes(current) && current !== '' && <option value={current}>{current}</option>}
75
+ {choices.map((choice) => <option key={choice} value={choice}>{choice}</option>)}
76
+ </select>;
77
+ } else if (type === 'number') {
78
+ const [min, max] = category.limits[name] ?? [undefined, undefined];
79
+ control = <input id={id} className="input" type="number" inputMode="numeric" min={min} max={max} step={1} disabled={disabled} title={title}
80
+ placeholder={fallback === undefined ? 'renderer default' : String(fallback)} value={typeof value === 'number' ? value : ''}
81
+ onChange={(event) => onChange(name, event.target.value)} />;
82
+ } else {
83
+ control = <input id={id} className="input" type="text" disabled={disabled} title={title} autoComplete="off" spellCheck={false}
84
+ placeholder={PLACEHOLDERS[name] ?? (fallback === undefined ? 'renderer default' : String(fallback))} value={typeof value === 'string' ? value : ''}
85
+ onChange={(event) => onChange(name, event.target.value)} />;
86
+ }
87
+ return <div className={className}>
88
+ <label className="field-label" htmlFor={id}>{fieldLabel(name)}</label>
89
+ {control}
90
+ {help && <span className={`field-help${error ? ' text-bad' : ''}`} role={error ? 'alert' : undefined}>{help}</span>}
91
+ </div>;
92
+ }
93
+
94
+ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory; categoryKey: string }) {
95
+ const queryClient = useQueryClient();
96
+ const rowsQuery = useQuery({ queryKey: rowQueryKey(category.key), queryFn: () => findCategoryRows(category.key), retry: false, refetchOnWindowFocus: false });
97
+ const rows = rowsQuery.data ?? [];
98
+ const row: SettingRow | null = rows[0] ?? null;
99
+ const duplicates = rows.length > 1;
100
+ const [draft, setDraft] = useState<CategoryDraft>(() => valueToDraft(category, category.override));
101
+ const [errors, setErrors] = useState<Record<string, string>>({});
102
+ const [formError, setFormError] = useState('');
103
+ const [busy, setBusy] = useState(false);
104
+ const roles = Object.keys(category.defaults);
105
+ const value = useMemo(() => draftToValue(category, draft), [category, draft]);
106
+ const dirty = !sameOverride(value, category.override);
107
+ // Hints wear the same slot and wording as a routed 400: the reason only, the role.field prefix is the label.
108
+ const hints = useMemo(() => Object.fromEntries(validateDraft(category, draft).map((entry) => [`${entry.role}.${entry.field}`, routeRenditionError(entry.message).message])), [category, draft]);
109
+ const hasH265 = roles.some((role) => category.defaults[role]?.codec === 'h265');
110
+
111
+ const change = (role: string, name: string, raw: unknown) => {
112
+ const kind = category.role_kinds[role] ?? '';
113
+ const typed = coerceFieldValue(fieldType(category, kind, name), raw);
114
+ setDraft((current) => ({ ...current, roles: { ...current.roles, [role]: { ...current.roles[role], [name]: typed } } }));
115
+ setErrors((current) => { const next = { ...current }; delete next[`${role}.${name}`]; delete next[role]; return next; });
116
+ setFormError('');
117
+ };
118
+ const toggleAutomatic = (role: string, on: boolean) => {
119
+ setDraft((current) => ({ ...current, automatic: on ? [...current.automatic.filter((entry) => entry !== role), role] : current.automatic.filter((entry) => entry !== role) }));
120
+ setErrors((current) => { const next = { ...current }; delete next[AUTOMATIC_KEY]; return next; });
121
+ setFormError('');
122
+ };
123
+ const refresh = async () => {
124
+ await Promise.all([
125
+ queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }),
126
+ queryClient.invalidateQueries({ queryKey: rowQueryKey(category.key) }),
127
+ ]);
128
+ };
129
+ const fail = (reason: unknown) => {
130
+ const message = reason instanceof Error ? reason.message : 'The rendition settings could not be saved';
131
+ const routed = routeRenditionError(message, [...roles, AUTOMATIC_KEY]);
132
+ if (routed.role === null) { setFormError(routed.message); return; }
133
+ const key = routed.field ? `${routed.role}.${routed.field}` : routed.role;
134
+ setErrors((current) => ({ ...current, [key]: routed.message }));
135
+ };
136
+ const save = async () => {
137
+ setBusy(true); setFormError(''); setErrors({});
138
+ try {
139
+ await saveCategoryValue(category, row, value);
140
+ toast.success(`${renditionCategoryLabel(categoryKey)} rendering saved`);
141
+ await refresh();
142
+ } catch (reason) {
143
+ fail(reason);
144
+ } finally {
145
+ setBusy(false);
146
+ }
147
+ };
148
+ const reset = async () => {
149
+ const ok = await modal.confirm({
150
+ title: `Reset ${renditionCategoryLabel(categoryKey).toLowerCase()} rendering to defaults?`,
151
+ message: 'Every role goes back to the framework defaults and the default set of roles runs on upload. Existing files are not touched.',
152
+ confirmText: 'Reset to defaults',
153
+ danger: true,
154
+ });
155
+ if (!ok) return;
156
+ setBusy(true); setFormError(''); setErrors({});
157
+ try {
158
+ await resetCategory(category, row);
159
+ toast.success(`${renditionCategoryLabel(categoryKey)} rendering reset to defaults`);
160
+ await refresh();
161
+ } catch (reason) {
162
+ fail(reason);
163
+ } finally {
164
+ setBusy(false);
165
+ }
166
+ };
167
+
168
+ const disabled = busy || duplicates || rowsQuery.isPending;
169
+ return <div className="media-rendering-panel">
170
+ {hasH265 && <p className="media-rendering-note">
171
+ <b>H.265 (HEVC)</b> encodes take several times longer than H.264 and do not play in every browser. Keep <code>video_mp4</code> as the H.264 compatibility rendition and enable <code>video_hevc</code> on upload only when the extra encode time is worth it.
172
+ </p>}
173
+ {rowsQuery.error && <div className="form-alert" role="alert">The current override could not be read: {rowsQuery.error.message} <button className="btn btn-compact" onClick={() => void rowsQuery.refetch()}>Retry</button></div>}
174
+ {duplicates && <div className="form-alert" role="alert">
175
+ More than one global row exists for <code>{category.key}</code> (ids {rows.map((entry) => entry.id).join(', ')}). The server reads an undefined one, so this page will not edit them; remove the duplicates on the server first.
176
+ </div>}
177
+ {formError && <div className="form-alert" role="alert">{formError}</div>}
178
+ <div className="media-rendering-roles">
179
+ {roles.map((role) => {
180
+ const kind = category.role_kinds[role] ?? '';
181
+ const hideMp4 = hidesMp4Options(category, draft, role);
182
+ const names = orderFields(category.fields[kind] ?? Object.keys(category.defaults[role] ?? {})).filter((name) => !(hideMp4 && (MP4_ONLY_OPTIONS as readonly string[]).includes(name)));
183
+ const roleError = errors[role];
184
+ const automatic = draft.automatic.includes(role);
185
+ const automaticDefault = category.automatic_default.includes(role);
186
+ const automaticEffective = category.effective.automatic.includes(role);
187
+ const rowHasError = Boolean(roleError) || names.some((name) => errors[`${role}.${name}`]);
188
+ return <div key={role} className={`media-rendering-role${rowHasError ? ' has-error' : ''}`}>
189
+ <div className="media-rendering-role-head">
190
+ <b>{renditionRoleLabel(role)}</b>
191
+ <code>{role}</code>
192
+ <span><Badge tone="muted">{kind || 'option'}</Badge></span>
193
+ </div>
194
+ <div className="media-rendering-fields">
195
+ {names.map((name) => <RoleField key={name} category={category} draft={draft} role={role} kind={kind} name={name}
196
+ error={errors[`${role}.${name}`] ?? hints[`${role}.${name}`]} disabled={disabled} onChange={(field, raw) => change(role, field, raw)} />)}
197
+ </div>
198
+ <div className="media-rendering-upload">
199
+ <label className={automatic === automaticDefault ? 'dim' : undefined}>
200
+ <input type="checkbox" role="switch" className="switch" checked={automatic} disabled={disabled} onChange={(event) => toggleAutomatic(role, event.target.checked)} />
201
+ Runs on upload
202
+ </label>
203
+ {automatic !== automaticEffective && <span className="field-help">In effect: {automaticEffective ? 'runs' : 'does not run'} (deployment file)</span>}
204
+ </div>
205
+ {roleError && <p className="media-rendering-role-error" role="alert">{roleError}</p>}
206
+ </div>;
207
+ })}
208
+ </div>
209
+ {errors[AUTOMATIC_KEY] && <div className="form-alert" role="alert">Runs on upload: {errors[AUTOMATIC_KEY]}</div>}
210
+ <div className="media-rendering-actions">
211
+ <button className="btn btn-primary" disabled={disabled || !dirty} onClick={() => void save()}>{busy ? 'Saving…' : 'Save'}</button>
212
+ <button className="btn" disabled={disabled || !row || category.override == null} onClick={() => void reset()}>Reset to defaults…</button>
213
+ <span className="dim">{row ? `Override row #${row.id}` : 'No override row yet — Save creates one.'}</span>
214
+ </div>
215
+ </div>;
216
+ }
217
+
218
+ function MediaRenderingContent({ options }: { options: RenditionOptions }) {
219
+ const categories = orderedCategories(options);
220
+ const items = categories.map((categoryKey) => {
221
+ const category = options.categories[categoryKey]!;
222
+ return {
223
+ key: categoryKey,
224
+ label: renditionCategoryLabel(categoryKey),
225
+ // Remount the panel whenever the override changes so the draft reseeds from the saved row.
226
+ panel: <CategoryPanel key={`${category.key}:${JSON.stringify(category.override ?? null)}`} category={category} categoryKey={categoryKey} />,
227
+ };
228
+ });
229
+ if (!items.length) return <div className="panel panel-pad">The backend declares no configurable rendition categories.</div>;
230
+ return <Tabs items={items} variant="underline" ariaLabel="Media rendering categories" />;
231
+ }
232
+
233
+ export function MediaRenderingPage() {
234
+ const query = useQuery({ queryKey: OPTIONS_QUERY_KEY, queryFn: readRenditionOptions, retry: false, refetchOnWindowFocus: false });
235
+ return <div className="page media-rendering-page">
236
+ <header className="page-header">
237
+ <div>
238
+ <div className="eyebrow">Infrastructure · Storage</div>
239
+ <h1>Media rendering</h1>
240
+ <p className="dim media-rendering-hint">
241
+ How thumbnails, previews and transcodes are produced, and which run after an upload. Only values that differ from the framework defaults are stored.
242
+ Changing these options does not touch existing files — open a file under Storage → Files and use <b>Regenerate renditions</b> to see the result.
243
+ </p>
244
+ </div>
245
+ </header>
246
+ {query.isPending && <div className="panel panel-pad">Loading rendition options…</div>}
247
+ {query.error && <div className="panel panel-pad">
248
+ <div className="form-alert" role="alert">{query.error.message}</div>
249
+ <button className="btn" onClick={() => void query.refetch()}>Retry</button>
250
+ </div>}
251
+ {query.data && <div className="panel panel-pad">
252
+ <MediaRenderingContent options={query.data} />
253
+ <p className="dim media-rendering-engine" style={{ marginTop: 14 }}>
254
+ Video engine: <code>{query.data.engine.effective}</code> ({query.data.engine.key} — edit on the Settings page when another engine exists). Descriptor: <code>{RENDITION_OPTIONS_ENDPOINT}</code>.
255
+ </p>
256
+ </div>}
257
+ </div>;
258
+ }
@@ -97,3 +97,15 @@ export async function pollRenditionConvergence(args: {
97
97
  }
98
98
  return 'timeout';
99
99
  }
100
+
101
+ const ROLE_ACRONYMS = new Set(['hevc', 'mp4', 'webm', 'pdf']);
102
+
103
+ /** `video_hevc` → "Video HEVC", `thumbnail_sm` → "Thumbnail sm". Display only; the role key is the identity. */
104
+ export function renditionRoleLabel(role: string): string {
105
+ const words = role.split('_').filter(Boolean);
106
+ if (!words.length) return role;
107
+ return words.map((word, index) => {
108
+ if (ROLE_ACRONYMS.has(word)) return word.toUpperCase();
109
+ return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word;
110
+ }).join(' ');
111
+ }
@@ -10,5 +10,7 @@ export * from './FileUploadSurface';
10
10
  export * from './FileView';
11
11
  export * from './FilePreview';
12
12
  export * from './storage-dialogs';
13
+ export * from './rendition-settings';
14
+ export * from './MediaRenderingPage';
13
15
 
14
16
  export { STORAGE_ADMIN_SECTION } from '../domains/infrastructure';
@@ -0,0 +1,264 @@
1
+ // Media rendering settings (#7729): the data layer behind the Storage →
2
+ // Media rendering page. Reads django-mojo's rendition descriptor
3
+ // (`GET /api/fileman/renditions/options`, fileman #7728) and writes the three
4
+ // FILEMAN_RENDITIONS_* settings through the ordinary `/api/settings` REST.
5
+ //
6
+ // Everything about roles, fields, choices and limits comes from the
7
+ // descriptor. The ONE rule this module hardcodes is the mp4-only trio
8
+ // (`codec`/`crf`/`preset`), which the server refuses on a non-mp4 role.
9
+ import { mojoCall, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
10
+ import type { SettingRow } from '../settings/model';
11
+
12
+ export type RenditionCategoryKey = 'image' | 'video' | 'document';
13
+ export type RenditionFieldType = 'number' | 'boolean' | 'choice' | 'string';
14
+ export type RenditionRoleOptions = Record<string, unknown>;
15
+
16
+ export interface RenditionCategory {
17
+ key: string;
18
+ defaults: Record<string, RenditionRoleOptions>;
19
+ automatic_default: string[];
20
+ /** The global DATABASE row, parsed, or null. A deployment-file value is applied but never reported here. */
21
+ override: Record<string, unknown> | null;
22
+ effective: { roles: Record<string, RenditionRoleOptions>; automatic: string[] };
23
+ role_kinds: Record<string, string>;
24
+ fields: Record<string, string[]>;
25
+ choices: Record<string, Record<string, string[]>>;
26
+ limits: Record<string, [number, number]>;
27
+ }
28
+
29
+ export interface RenditionOptions {
30
+ categories: Partial<Record<RenditionCategoryKey, RenditionCategory>> & Record<string, RenditionCategory>;
31
+ engine: { key: string; choices: string[]; effective: string };
32
+ }
33
+
34
+ export interface CategoryDraft {
35
+ roles: Record<string, RenditionRoleOptions>;
36
+ automatic: string[];
37
+ }
38
+
39
+ export interface RenditionFieldError {
40
+ role: string;
41
+ field: string | null;
42
+ message: string;
43
+ }
44
+
45
+ /** A routed server refusal: `role` null = panel-level, `_automatic` = the upload switches. */
46
+ export interface RoutedRenditionError {
47
+ role: string | null;
48
+ field: string | null;
49
+ message: string;
50
+ }
51
+
52
+ export const RENDITION_OPTIONS_ENDPOINT = '/api/fileman/renditions/options';
53
+ export const RENDITION_CATEGORY_ORDER: RenditionCategoryKey[] = ['image', 'video', 'document'];
54
+ export const RENDITION_CATEGORY_LABELS: Record<RenditionCategoryKey, string> = { image: 'Images', video: 'Video', document: 'Documents' };
55
+ export const AUTOMATIC_KEY = '_automatic';
56
+ /** Options only an mp4 transcode accepts; the server refuses them on a webm role (`config.MP4_ONLY_OPTIONS`). */
57
+ export const MP4_ONLY_OPTIONS = ['codec', 'crf', 'preset'] as const;
58
+ const BITRATE_RE = /^\d+[kKmM]?$/;
59
+ const TIME_OFFSET_RE = /^\d{2}:\d{2}:\d{2}(\.\d+)?$/;
60
+
61
+ export function renditionCategoryLabel(category: string): string {
62
+ const label = (RENDITION_CATEGORY_LABELS as Record<string, string>)[category];
63
+ if (label) return label;
64
+ console.warn(`Media rendering: unknown rendition category "${category}" — rendering its raw key`);
65
+ return category;
66
+ }
67
+
68
+ /** The categories to render, in house order, then any the descriptor adds. */
69
+ export function orderedCategories(options: RenditionOptions): string[] {
70
+ const served = Object.keys(options.categories);
71
+ const known = RENDITION_CATEGORY_ORDER.filter((category) => served.includes(category));
72
+ return [...known, ...served.filter((category) => !(RENDITION_CATEGORY_ORDER as string[]).includes(category))];
73
+ }
74
+
75
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
76
+ return value != null && typeof value === 'object' && !Array.isArray(value);
77
+ }
78
+
79
+ export async function readRenditionOptions(): Promise<RenditionOptions> {
80
+ const body = await mojoCall(RENDITION_OPTIONS_ENDPOINT);
81
+ const data = body.data as RenditionOptions | undefined;
82
+ if (!data || !isPlainObject(data.categories)) throw new Error('Rendition options returned an unsupported response');
83
+ for (const [category, descriptor] of Object.entries(data.categories)) {
84
+ if (!descriptor || typeof descriptor.key !== 'string' || !isPlainObject(descriptor.defaults) || !isPlainObject(descriptor.role_kinds) || !isPlainObject(descriptor.fields)) {
85
+ throw new Error(`Rendition options for "${category}" are incomplete`);
86
+ }
87
+ }
88
+ return data;
89
+ }
90
+
91
+ /**
92
+ * The global rows for a category key. The options endpoint does not carry a
93
+ * row id, so one list read finds the row to update. Raw `mojoList` on
94
+ * purpose: `SettingModel.normalizeListParams` strips `key`.
95
+ */
96
+ export async function findCategoryRows(key: string): Promise<SettingRow[]> {
97
+ const { rows } = await mojoList<SettingRow>('/api/settings', { key, group__isnull: true, size: 10 });
98
+ return rows.filter((row) => row.key === key && row.group == null).sort((a, b) => a.id - b.id);
99
+ }
100
+
101
+ /** The one typing rule: render, coerce, compare, validate and emit all use it. */
102
+ export function fieldType(category: RenditionCategory, kind: string, name: string): RenditionFieldType {
103
+ // Choices first: the server lists a limit for every option NAME it bounds anywhere in the
104
+ // category, so document `quality` (a choice on pdf roles) also appears under `limits`.
105
+ if (category.choices[kind]?.[name]) return 'choice';
106
+ if (name in category.limits) return 'number';
107
+ const boolean = Object.entries(category.defaults).some(([role, options]) => category.role_kinds[role] === kind && typeof options[name] === 'boolean');
108
+ return boolean ? 'boolean' : 'string';
109
+ }
110
+
111
+ /** Coerce a raw control value into the draft's typed value (`null` = unset). */
112
+ export function coerceFieldValue(type: RenditionFieldType, raw: unknown): unknown {
113
+ if (raw === '' || raw == null) return null;
114
+ if (type === 'number') {
115
+ const value = typeof raw === 'number' ? raw : Number(String(raw).trim());
116
+ return Number.isFinite(value) ? value : null;
117
+ }
118
+ if (type === 'boolean') return raw === true || raw === 'true';
119
+ return String(raw);
120
+ }
121
+
122
+ function isSet(value: unknown): boolean {
123
+ return value !== null && value !== undefined && value !== '';
124
+ }
125
+
126
+ /** Draft = defaults shallow-merged with the override. Never seeded from `effective` (see the page docs). */
127
+ export function valueToDraft(category: RenditionCategory, value: Record<string, unknown> | null | undefined): CategoryDraft {
128
+ const override = isPlainObject(value) ? value : {};
129
+ const roles: Record<string, RenditionRoleOptions> = {};
130
+ for (const [role, base] of Object.entries(category.defaults)) {
131
+ const extra = override[role];
132
+ roles[role] = { ...base, ...(isPlainObject(extra) ? extra : {}) };
133
+ }
134
+ const automatic = override[AUTOMATIC_KEY];
135
+ return {
136
+ roles,
137
+ automatic: Array.isArray(automatic)
138
+ ? automatic.filter((role): role is string => typeof role === 'string' && role in category.defaults)
139
+ : [...category.automatic_default],
140
+ };
141
+ }
142
+
143
+ export function roleFormat(category: RenditionCategory, draft: CategoryDraft, role: string): string | null {
144
+ const value = draft.roles[role]?.format ?? category.defaults[role]?.format;
145
+ return typeof value === 'string' ? value : null;
146
+ }
147
+
148
+ /** Whether a kind carries the mp4-only trio and this role's format is not mp4. */
149
+ export function hidesMp4Options(category: RenditionCategory, draft: CategoryDraft, role: string): boolean {
150
+ const kind = category.role_kinds[role];
151
+ if (!kind || !category.fields[kind]?.includes('codec')) return false;
152
+ return (roleFormat(category, draft, role) ?? 'mp4') !== 'mp4';
153
+ }
154
+
155
+ export function sameValue(a: unknown, b: unknown): boolean {
156
+ if (!isSet(a) && !isSet(b)) return true;
157
+ return a === b;
158
+ }
159
+
160
+ /** True when the draft's value for `role.name` is the default (or unset with no default). */
161
+ export function isDefaultValue(category: RenditionCategory, draft: CategoryDraft, role: string, name: string): boolean {
162
+ return sameValue(draft.roles[role]?.[name], category.defaults[role]?.[name]);
163
+ }
164
+
165
+ function sameSet(a: readonly string[], b: readonly string[]): boolean {
166
+ const left = new Set(a);
167
+ const right = new Set(b);
168
+ return left.size === right.size && [...left].every((entry) => right.has(entry));
169
+ }
170
+
171
+ /** `_automatic` as the server should store it: default order first, additions in `defaults` key order. */
172
+ export function orderedAutomatic(category: RenditionCategory, automatic: readonly string[]): string[] {
173
+ const chosen = new Set(automatic);
174
+ const kept = category.automatic_default.filter((role) => chosen.has(role));
175
+ const added = Object.keys(category.defaults).filter((role) => chosen.has(role) && !category.automatic_default.includes(role));
176
+ return [...kept, ...added];
177
+ }
178
+
179
+ /**
180
+ * The override to write: only options that differ from the defaults, the
181
+ * mp4-only trio dropped on a non-mp4 role, `_automatic` only when the set
182
+ * differs from `automatic_default`. `{}` means "defaults".
183
+ */
184
+ export function draftToValue(category: RenditionCategory, draft: CategoryDraft): Record<string, unknown> {
185
+ const value: Record<string, unknown> = {};
186
+ for (const role of Object.keys(category.defaults)) {
187
+ const kind = category.role_kinds[role] ?? '';
188
+ const names = category.fields[kind] ?? Object.keys(category.defaults[role] ?? {});
189
+ const hideMp4 = hidesMp4Options(category, draft, role);
190
+ const changed: RenditionRoleOptions = {};
191
+ for (const name of names) {
192
+ if (hideMp4 && (MP4_ONLY_OPTIONS as readonly string[]).includes(name)) continue;
193
+ const current = draft.roles[role]?.[name];
194
+ if (!isSet(current)) continue;
195
+ if (sameValue(current, category.defaults[role]?.[name])) continue;
196
+ changed[name] = coerceFieldValue(fieldType(category, kind, name), current);
197
+ }
198
+ if (Object.keys(changed).length) value[role] = changed;
199
+ }
200
+ if (!sameSet(draft.automatic, category.automatic_default)) value[AUTOMATIC_KEY] = orderedAutomatic(category, draft.automatic);
201
+ return value;
202
+ }
203
+
204
+ export function sameOverride(a: Record<string, unknown> | null | undefined, b: Record<string, unknown> | null | undefined): boolean {
205
+ return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
206
+ }
207
+
208
+ /** Client-side mirror of the server's checks, worded identically. Advisory: the server decides. */
209
+ export function validateDraft(category: RenditionCategory, draft: CategoryDraft): RenditionFieldError[] {
210
+ const errors: RenditionFieldError[] = [];
211
+ for (const role of Object.keys(category.defaults)) {
212
+ const kind = category.role_kinds[role] ?? '';
213
+ const hideMp4 = hidesMp4Options(category, draft, role);
214
+ for (const name of category.fields[kind] ?? []) {
215
+ if (hideMp4 && (MP4_ONLY_OPTIONS as readonly string[]).includes(name)) continue;
216
+ const current = draft.roles[role]?.[name];
217
+ if (!isSet(current)) continue;
218
+ const where = `${role}.${name}`;
219
+ const type = fieldType(category, kind, name);
220
+ if (type === 'number') {
221
+ const [low, high] = category.limits[name]!;
222
+ if (typeof current !== 'number' || !Number.isInteger(current)) errors.push({ role, field: name, message: `${where}: must be a whole number` });
223
+ else if (current < low || current > high) errors.push({ role, field: name, message: `${where}: must be between ${low} and ${high}` });
224
+ } else if (type === 'choice') {
225
+ const allowed = category.choices[kind]?.[name] ?? [];
226
+ if (typeof current !== 'string' || !allowed.includes(current)) errors.push({ role, field: name, message: `${where}: must be one of ${allowed.join(', ')}` });
227
+ } else if (type === 'boolean') {
228
+ if (typeof current !== 'boolean') errors.push({ role, field: name, message: `${where}: must be true or false` });
229
+ } else if (name === 'bitrate') {
230
+ if (typeof current !== 'string' || !BITRATE_RE.test(current)) errors.push({ role, field: name, message: `${where}: must look like 2000k or 2M` });
231
+ } else if (name === 'time_offset') {
232
+ if (typeof current !== 'string' || !TIME_OFFSET_RE.test(current)) errors.push({ role, field: name, message: `${where}: must look like HH:MM:SS` });
233
+ }
234
+ }
235
+ }
236
+ return errors;
237
+ }
238
+
239
+ /**
240
+ * Route a server refusal to the thing it names. Grammar (`config.validate`):
241
+ * `<role>.<option>: …`, `<role>: …`, `_automatic: …`, or anything else
242
+ * (panel-level).
243
+ */
244
+ export function routeRenditionError(message: string, knownRoles: readonly string[] = []): RoutedRenditionError {
245
+ const trimmed = message.trim();
246
+ const match = /^([A-Za-z0-9_]+)(?:\.([A-Za-z0-9_]+))?:\s*(.+)$/s.exec(trimmed);
247
+ if (!match) return { role: null, field: null, message: trimmed };
248
+ const [, role, field, rest] = match;
249
+ if (role === AUTOMATIC_KEY) return { role: AUTOMATIC_KEY, field: null, message: rest! };
250
+ if (knownRoles.length && !knownRoles.includes(role!)) return { role: null, field: null, message: trimmed };
251
+ return { role: role!, field: field ?? null, message: rest! };
252
+ }
253
+
254
+ /** Create or update the category's global row. Imperative; a failure rejects. */
255
+ export async function saveCategoryValue(category: RenditionCategory, row: SettingRow | null, value: Record<string, unknown>): Promise<SettingRow> {
256
+ const changes: Record<string, unknown> = row ? { value } : { key: category.key, is_secret: false, group: null, value };
257
+ return withFreshAuth(() => mojoSave<SettingRow>('/api/settings', row?.id ?? null, changes));
258
+ }
259
+
260
+ /** Reset = write `{}`. The live Setting model refuses DELETE, and `{}` is reported as `override: null`. */
261
+ export async function resetCategory(category: RenditionCategory, row: SettingRow | null): Promise<SettingRow | null> {
262
+ if (!row) return null;
263
+ return saveCategoryValue(category, row, {});
264
+ }
@@ -4,7 +4,7 @@
4
4
  // or contains an <svg>.
5
5
  //
6
6
  // Additions over the source, both needed because portal-mojo charts style
7
- // through theme.css CLASSES (which a serialized clone loses):
7
+ // through portal-mojo/styles.css CLASSES (which a serialized clone loses):
8
8
  // · computed styles are inlined onto the clone (fixed property list), so
9
9
  // token-driven fills/strokes/fonts survive serialization;
10
10
  // · the backdrop defaults to the nearest ancestor's real background color