@pramen/cms-editor 0.0.61 → 0.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -4
- package/dist/editor.css +1 -1
- package/dist/editor.js +155 -155
- package/dist/panel-jsx-dev-runtime.js +8 -0
- package/dist/panel-jsx-runtime.js +8 -0
- package/dist/panel-react-dom.js +8 -0
- package/dist/panel-react.js +8 -0
- package/package.json +8 -3
- package/src/api.ts +23 -6
- package/src/app-context.tsx +13 -3
- package/src/app.css +48 -0
- package/src/blockkit.tsx +109 -32
- package/src/breadcrumb.tsx +48 -0
- package/src/chrome.ts +27 -0
- package/src/components.tsx +624 -183
- package/src/cover.tsx +163 -0
- package/src/furniture.tsx +121 -27
- package/src/icons.tsx +97 -0
- package/src/main.tsx +18 -0
- package/src/nav.ts +149 -15
- package/src/page-header.tsx +128 -0
- package/src/panel-boundary.tsx +67 -0
- package/src/panel-globals.ts +103 -0
- package/src/panel-runtime.ts +105 -0
- package/src/panels.ts +420 -0
- package/src/preview.ts +65 -0
- package/src/routes/_layout.tsx +515 -99
- package/src/routes/admin-page.tsx +77 -3
- package/src/routes/page.tsx +4 -0
- package/src/theme.ts +80 -0
- package/src/types.ts +103 -7
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
// A
|
|
1
|
+
// A project's own screen (`/apps/:slug`), rendered inside the editor's own chrome.
|
|
2
2
|
//
|
|
3
3
|
// Routed BY THE EDITOR, which is the point: an `extraNav` link has to open a new tab,
|
|
4
4
|
// because `_404.tsx` registers the catch-all `/:__notFound+` and a same-tab click on an
|
|
5
|
-
// unmounted editor lands on the in-app 404. A registered
|
|
5
|
+
// unmounted editor lands on the in-app 404. A registered screen has a real route, so it is
|
|
6
6
|
// part of the admin rather than a link out of it.
|
|
7
|
+
//
|
|
8
|
+
// ONE route for two kinds. A Block Kit page (`adminPage()`) is described as JSON by the
|
|
9
|
+
// server and rendered here; a panel (`adminPanel()`) is a React component the deployment's
|
|
10
|
+
// own bundle registered. They share this route because they share everything a URL and a nav
|
|
11
|
+
// entry are made of — the slug space, the role filter, the "Apps" band, the breadcrumb — and
|
|
12
|
+
// differ only in where the rendering happens. Splitting them would have meant a second
|
|
13
|
+
// route, a second nav band and a slug that could mean two things.
|
|
7
14
|
|
|
8
|
-
import { createPage, useNavigate } from "@buzola/router";
|
|
15
|
+
import { createPage, useNavigate, useRouter } from "@buzola/router";
|
|
16
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
9
17
|
import { useApp } from "../app-context";
|
|
10
18
|
import { AdminPageView } from "../blockkit";
|
|
11
19
|
import { Notice } from "../components";
|
|
20
|
+
import { PanelBoundary } from "../panel-boundary";
|
|
21
|
+
import { getPanel, panelRefusal, panelsSettled, panelsVersion, subscribePanels, type PanelProps } from "../panels";
|
|
22
|
+
import { useTheme } from "../theme";
|
|
23
|
+
import { adminPageKind } from "../types";
|
|
12
24
|
import { Button } from "@podoba/react";
|
|
13
25
|
|
|
14
26
|
export default createPage()
|
|
@@ -29,8 +41,70 @@ export default createPage()
|
|
|
29
41
|
</Notice>
|
|
30
42
|
);
|
|
31
43
|
}
|
|
44
|
+
if (adminPageKind(def) === "panel") return <PanelRoute slug={def.slug} />;
|
|
32
45
|
// Keyed on the slug so switching between two pages REMOUNTS the view: buzola renders the
|
|
33
46
|
// same component instance across a params-only change, and the blocks, the form values
|
|
34
47
|
// and any toast all belong to one page.
|
|
35
48
|
return <AdminPageView api={api} key={def.slug} slug={def.slug} label={def.label} onError={setError} />;
|
|
36
49
|
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A panel: the component the deployment's own bundle registered for this slug.
|
|
53
|
+
*
|
|
54
|
+
* The registry is external state that changes without React knowing — bundles are imported
|
|
55
|
+
* from `main.tsx` and register whenever they land — so it is read through
|
|
56
|
+
* `useSyncExternalStore` rather than an effect. That is what makes a deep link to a panel
|
|
57
|
+
* work: the route can mount before the bundle has finished loading, and re-renders when it
|
|
58
|
+
* has, instead of deciding once and being wrong forever.
|
|
59
|
+
*/
|
|
60
|
+
function PanelRoute({ slug }: { slug: string }) {
|
|
61
|
+
const { api, setError } = useApp();
|
|
62
|
+
const theme = useTheme();
|
|
63
|
+
const basePath = useRouter().basePath;
|
|
64
|
+
const version = useSyncExternalStore(subscribePanels, panelsVersion, panelsVersion);
|
|
65
|
+
// `version` is the snapshot, not the value — a stable number is what a store hook needs,
|
|
66
|
+
// and the lookup is what the render actually wants. Depending on it is the point.
|
|
67
|
+
const panel = useMemo(() => getPanel(slug), [slug, version]);
|
|
68
|
+
|
|
69
|
+
if (!panel) {
|
|
70
|
+
// Three different situations, and only the first is a spinner.
|
|
71
|
+
//
|
|
72
|
+
// A REFUSAL comes first because it is the one the generic message would actively mislead
|
|
73
|
+
// about: the bundle is listed, it loaded, it ran, and it called `registerPanel` — telling
|
|
74
|
+
// the reader to go and check those four things sends them past the actual answer. The
|
|
75
|
+
// registry already holds a sentence naming the slug and the fix (a contract built against
|
|
76
|
+
// a different editor, a `render` that is not a component), so it is shown verbatim.
|
|
77
|
+
//
|
|
78
|
+
// Otherwise: the server listed this panel (it is in `adminPages`, so the caller may open
|
|
79
|
+
// it), which means the bundle either has not landed yet or landed and never registered
|
|
80
|
+
// this slug. The last is a deployment mistake — a missing `panels` entry, a bundle built
|
|
81
|
+
// without the `registerPanel` call, a slug typo between `app.ts` and the bundle — and it
|
|
82
|
+
// is one nobody can diagnose from a spinner.
|
|
83
|
+
//
|
|
84
|
+
// Read during render, not through a second store: a refusal calls the same `notify` a
|
|
85
|
+
// registration does, so `version` above is already the subscription that brings this
|
|
86
|
+
// component back when one is recorded.
|
|
87
|
+
const refused = panelRefusal(slug);
|
|
88
|
+
return (
|
|
89
|
+
<Notice>
|
|
90
|
+
{refused ??
|
|
91
|
+
(panelsSettled()
|
|
92
|
+
? `No panel is registered for '${slug}'. Check that this deployment's panel bundle is listed in the admin's \`panels\` config and calls registerPanel({ slug: "${slug}", … }).`
|
|
93
|
+
: "Loading…")}
|
|
94
|
+
</Notice>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const props: PanelProps = { api, basePath, theme, setError };
|
|
99
|
+
// Keyed on the slug for the same reason a Block Kit page is: buzola keeps one component
|
|
100
|
+
// instance across a params-only change, and a panel's state belongs to its own screen.
|
|
101
|
+
//
|
|
102
|
+
// Wrapped, because this is someone else's component in our tree: React unmounts the whole
|
|
103
|
+
// root on an uncaught render error, so without the boundary one bad panel does not break a
|
|
104
|
+
// screen, it blanks the admin — no sidebar, and no way off the route that is failing.
|
|
105
|
+
return (
|
|
106
|
+
<PanelBoundary key={slug} slug={slug}>
|
|
107
|
+
<panel.render {...props} />
|
|
108
|
+
</PanelBoundary>
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/routes/page.tsx
CHANGED
|
@@ -73,6 +73,10 @@ export default createPage()
|
|
|
73
73
|
tab={tab}
|
|
74
74
|
onTab={setTab}
|
|
75
75
|
onBack={backToList}
|
|
76
|
+
// Named for where it actually goes. On a per-type deployment `backToList` lands in
|
|
77
|
+
// this page's OWN type list, and a button labelled "Pages" then named a pooled list
|
|
78
|
+
// that deployment does not have.
|
|
79
|
+
backLabel={splitsByType(contentTypes, cms) && ownType ? ownType.name : "Pages"}
|
|
76
80
|
onChange={setPage}
|
|
77
81
|
registerGuard={setNavGuard}
|
|
78
82
|
/>
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The chrome's light/dark choice, as a store rather than a component's state.
|
|
2
|
+
//
|
|
3
|
+
// It used to be a `useState` inside `_layout.tsx`, which was fine while the layout was the
|
|
4
|
+
// only reader. It is not the only reader any more: a panel (a project's own React screen —
|
|
5
|
+
// see `panels.ts`) is handed the theme, and it is rendered through `<Outlet />` under that
|
|
6
|
+
// same layout. Threading it down as a prop would mean every route the layout renders had to
|
|
7
|
+
// carry a value only one of them wants; a second `useState` would mean two sources of truth
|
|
8
|
+
// for one document attribute, drifting the moment either is set.
|
|
9
|
+
//
|
|
10
|
+
// A store instead: one value, one place that writes the DOM attribute and localStorage, and
|
|
11
|
+
// a `useSyncExternalStore` hook for anyone who wants to re-render on a change. It is also
|
|
12
|
+
// what lets `main.tsx` apply the stored theme BEFORE the first paint, which the layout's
|
|
13
|
+
// effect could not do — an editor left in dark mode used to flash white on every load.
|
|
14
|
+
|
|
15
|
+
import { useSyncExternalStore } from "react";
|
|
16
|
+
|
|
17
|
+
export type Theme = "light" | "dark";
|
|
18
|
+
|
|
19
|
+
/** Per-browser, like the folded nav groups: a reading preference, not deployment
|
|
20
|
+
* configuration, so nothing server-side carries it. */
|
|
21
|
+
const THEME_KEY = "pramen.cms.theme";
|
|
22
|
+
|
|
23
|
+
let current: Theme = "light";
|
|
24
|
+
const listeners = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
/** Read the stored choice, tolerating every shape localStorage can be in (absent, another
|
|
27
|
+
* version's value, hand-edited, a private window that throws on access). Anything that is
|
|
28
|
+
* not exactly `"dark"` is light, which is the default the editor has always had. */
|
|
29
|
+
function stored(): Theme {
|
|
30
|
+
try {
|
|
31
|
+
return localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
|
|
32
|
+
} catch {
|
|
33
|
+
return "light";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Put the choice where podoba can see it. The tokens flip under `[data-theme="dark"]` on
|
|
38
|
+
* the document root — there are no `dark:` variants to toggle — so this one attribute is the
|
|
39
|
+
* whole of "apply the theme". */
|
|
40
|
+
function apply(theme: Theme): void {
|
|
41
|
+
if (typeof document !== "undefined") document.documentElement.dataset.theme = theme;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Adopt the stored choice and paint it. Called once, from `main.tsx`, before `createRoot`. */
|
|
45
|
+
export function initTheme(): void {
|
|
46
|
+
current = stored();
|
|
47
|
+
apply(current);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getTheme(): Theme {
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setTheme(theme: Theme): void {
|
|
55
|
+
if (theme === current) return;
|
|
56
|
+
current = theme;
|
|
57
|
+
apply(theme);
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
60
|
+
} catch {
|
|
61
|
+
// A private window that refuses writes costs the memory of the choice, nothing else.
|
|
62
|
+
}
|
|
63
|
+
for (const listener of [...listeners]) listener();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function subscribeTheme(listener: () => void): () => void {
|
|
67
|
+
listeners.add(listener);
|
|
68
|
+
return () => listeners.delete(listener);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The current theme, re-rendering the caller when it changes. */
|
|
72
|
+
export function useTheme(): Theme {
|
|
73
|
+
return useSyncExternalStore(subscribeTheme, getTheme, getTheme);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drop every listener and return to the default. Tests only — the store is module state. */
|
|
77
|
+
export function resetTheme(): void {
|
|
78
|
+
listeners.clear();
|
|
79
|
+
current = "light";
|
|
80
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -209,6 +209,11 @@ export interface CmsCapabilities {
|
|
|
209
209
|
* configured. Without it a reviewer-only session gets the authoring nav and every screen
|
|
210
210
|
* 403s on its first save. */
|
|
211
211
|
canEdit: boolean;
|
|
212
|
+
/** The server has `listMediaTerms`/`setMediaTerms`, and `listMedia` understands `term`.
|
|
213
|
+
* Declared like `siteFurniture`: on an older server the detail panel's Tags section would
|
|
214
|
+
* 404 the moment a file is opened, and the library's tag filter would send an argument
|
|
215
|
+
* that is silently ignored — a control that visibly does nothing. */
|
|
216
|
+
mediaTerms: boolean;
|
|
212
217
|
}
|
|
213
218
|
|
|
214
219
|
/**
|
|
@@ -244,6 +249,7 @@ export const DEFAULT_CAPABILITIES: CmsCapabilities = {
|
|
|
244
249
|
pagesByType: false,
|
|
245
250
|
siteFurniture: false,
|
|
246
251
|
codeDefinedTypes: false,
|
|
252
|
+
mediaTerms: false,
|
|
247
253
|
// Fails OPEN, unlike its neighbours. An older server sends no `canEdit`, and hiding
|
|
248
254
|
// every authoring control from a real editor is unrecoverable from inside the editor;
|
|
249
255
|
// showing one that 403s is a legible error with a way forward. The server is the
|
|
@@ -371,8 +377,25 @@ export interface Taxonomy {
|
|
|
371
377
|
pluralLabel?: string | null;
|
|
372
378
|
description?: string | null;
|
|
373
379
|
hierarchical?: boolean;
|
|
380
|
+
/** What this vocabulary classifies. `null`/absent means EVERYTHING — a vocabulary that was
|
|
381
|
+
* never narrowed, and the reading for a row written before the column existed. */
|
|
382
|
+
appliesTo?: TaxonomyTarget[] | null;
|
|
374
383
|
}
|
|
375
384
|
|
|
385
|
+
/** Mirror of @pramen/cms `TAXONOMY_TARGETS` — the object types a vocabulary can classify.
|
|
386
|
+
* Mirrored rather than imported for the reason at the top of this file: the editor is a
|
|
387
|
+
* standalone browser app with no server-package dependency. The server is the authority; an
|
|
388
|
+
* unknown value here would just render an unchecked box it refuses to save. */
|
|
389
|
+
export const TAXONOMY_TARGETS = ["page", "media"] as const;
|
|
390
|
+
export type TaxonomyTarget = (typeof TAXONOMY_TARGETS)[number];
|
|
391
|
+
|
|
392
|
+
/** What each target is called on screen — plural, because each names the SET of things the
|
|
393
|
+
* vocabulary would classify. */
|
|
394
|
+
export const TAXONOMY_TARGET_LABELS = {
|
|
395
|
+
page: "Pages",
|
|
396
|
+
media: "Media",
|
|
397
|
+
} satisfies Record<TaxonomyTarget, string>;
|
|
398
|
+
|
|
376
399
|
export interface Term {
|
|
377
400
|
id: string;
|
|
378
401
|
taxonomyId: string;
|
|
@@ -408,15 +431,19 @@ export interface WidgetArea {
|
|
|
408
431
|
|
|
409
432
|
// --- Block Kit: custom admin pages (mirrors @pramen/cms `./blockkit`) ----------------
|
|
410
433
|
|
|
411
|
-
/** An input a Block Kit form
|
|
434
|
+
/** An input a Block Kit form, actions row or table cell can carry.
|
|
435
|
+
*
|
|
436
|
+
* `error` is the per-FIELD failure, drawn under the input it belongs to. It is part of the
|
|
437
|
+
* render, not client state: the whole page comes back on every interaction, so an error
|
|
438
|
+
* lasts exactly as long as the response that carried it. */
|
|
412
439
|
export type AdminInput =
|
|
413
|
-
| { type: "text_input"; action_id: string; label?: string; placeholder?: string; initial_value?: string; multiline?: boolean; required?: boolean }
|
|
414
|
-
| { type: "number_input"; action_id: string; label?: string; placeholder?: string; initial_value?: number; min?: number; max?: number; required?: boolean }
|
|
415
|
-
| { type: "select"; action_id: string; label?: string; options: { value: string; label: string }[]; initial_value?: string; required?: boolean }
|
|
416
|
-
| { type: "toggle"; action_id: string; label?: string; initial_value?: boolean }
|
|
440
|
+
| { type: "text_input"; action_id: string; label?: string; placeholder?: string; initial_value?: string; multiline?: boolean; required?: boolean; error?: string }
|
|
441
|
+
| { type: "number_input"; action_id: string; label?: string; placeholder?: string; initial_value?: number; min?: number; max?: number; required?: boolean; error?: string }
|
|
442
|
+
| { type: "select"; action_id: string; label?: string; options: { value: string; label: string }[]; initial_value?: string; required?: boolean; error?: string }
|
|
443
|
+
| { type: "toggle"; action_id: string; label?: string; initial_value?: boolean; error?: string }
|
|
417
444
|
/** Write-only: deliberately has NO `initial_value`, so a stored secret is never echoed
|
|
418
445
|
* back into the admin's DOM. */
|
|
419
|
-
| { type: "secret_input"; action_id: string; label?: string; placeholder?: string; required?: boolean };
|
|
446
|
+
| { type: "secret_input"; action_id: string; label?: string; placeholder?: string; required?: boolean; error?: string };
|
|
420
447
|
|
|
421
448
|
export interface AdminButton {
|
|
422
449
|
type: "button";
|
|
@@ -430,13 +457,24 @@ export interface AdminButton {
|
|
|
430
457
|
|
|
431
458
|
export type AdminElement = AdminButton | AdminInput;
|
|
432
459
|
|
|
460
|
+
/** Every `AdminElement` tag, as a runtime set — the editor needs it to decide whether a
|
|
461
|
+
* table cell draws as text or as a control. Mirrors `@pramen/cms`; the two are asserted
|
|
462
|
+
* equal in `test/cms-editor-mirrors.test.ts`, because a tag missing here renders a live
|
|
463
|
+
* control as `[object Object]`. */
|
|
464
|
+
export const ADMIN_ELEMENT_TYPES = ["button", "text_input", "number_input", "select", "toggle", "secret_input"] as const;
|
|
465
|
+
|
|
466
|
+
/** What one table cell holds: a value to READ, or an element to ACT with. Told apart by
|
|
467
|
+
* shape — a display value is a primitive, an element is an object. The server refuses any
|
|
468
|
+
* other object on the way out. */
|
|
469
|
+
export type AdminCell = string | number | boolean | null | AdminElement;
|
|
470
|
+
|
|
433
471
|
export type AdminBlock =
|
|
434
472
|
| { type: "header"; text: string; level?: 1 | 2 | 3 }
|
|
435
473
|
| { type: "section"; text: string }
|
|
436
474
|
| { type: "divider" }
|
|
437
475
|
| { type: "context"; text: string }
|
|
438
476
|
| { type: "fields"; fields: { label: string; value: string }[] }
|
|
439
|
-
| { type: "table"; columns: { key: string; label: string }[]; rows: Record<string,
|
|
477
|
+
| { type: "table"; block_id?: string; columns: { key: string; label: string }[]; rows: Record<string, AdminCell>[]; empty?: string }
|
|
440
478
|
| { type: "stats"; stats: { label: string; value: string; hint?: string }[] }
|
|
441
479
|
| { type: "actions"; block_id?: string; elements: AdminElement[] }
|
|
442
480
|
| { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: string; action_id: string } }
|
|
@@ -451,6 +489,15 @@ export interface AdminPageResponse {
|
|
|
451
489
|
toast?: { text: string; tone?: "info" | "success" | "error" };
|
|
452
490
|
}
|
|
453
491
|
|
|
492
|
+
/** How a registered admin screen draws. Mirror of `ADMIN_PAGE_KINDS` in @pramen/cms.
|
|
493
|
+
*
|
|
494
|
+
* `"blocks"` is Block Kit — the server describes the page as JSON and the editor renders it.
|
|
495
|
+
* `"panel"` is a React component the deployment's own panel bundle registered under the
|
|
496
|
+
* same slug (see `panels.ts`); the server still owns the entry, so the role filter, the
|
|
497
|
+
* label, the icon and the position are the same server facts for both. */
|
|
498
|
+
export const ADMIN_PAGE_KINDS = ["blocks", "panel"] as const;
|
|
499
|
+
export type AdminPageKind = (typeof ADMIN_PAGE_KINDS)[number];
|
|
500
|
+
|
|
454
501
|
/** A custom admin page, as the editor sees it (from `listAdminPages`) — never the render
|
|
455
502
|
* function, and never the role list. A page the caller may not open is simply absent. */
|
|
456
503
|
export interface AdminPageMeta {
|
|
@@ -458,4 +505,53 @@ export interface AdminPageMeta {
|
|
|
458
505
|
label: string;
|
|
459
506
|
icon?: string;
|
|
460
507
|
navOrder?: number;
|
|
508
|
+
/** Absent ⇒ `"blocks"`, which is what every entry was before panels existed and what an
|
|
509
|
+
* older server still sends. Read through {@link adminPageKind} rather than directly, so
|
|
510
|
+
* the default lives in one place and an unrecognised kind from a NEWER server degrades to
|
|
511
|
+
* a Block Kit page (which renders a legible server error) instead of a blank screen. */
|
|
512
|
+
kind?: string;
|
|
461
513
|
}
|
|
514
|
+
|
|
515
|
+
/** The kind an entry actually is, defaulted and validated. */
|
|
516
|
+
export function adminPageKind(meta: AdminPageMeta): AdminPageKind {
|
|
517
|
+
return (ADMIN_PAGE_KINDS as readonly string[]).includes(meta.kind ?? "") ? (meta.kind as AdminPageKind) : "blocks";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// --- media sorting and filtering -----------------------------------------------------------
|
|
521
|
+
//
|
|
522
|
+
// Mirrors of the vocabularies `listMedia` accepts in @pramen/cms. Mirrored rather than
|
|
523
|
+
// imported for the reason at the top of this file — the editor is a standalone browser app
|
|
524
|
+
// with no server-package dependency — and checked against the originals in
|
|
525
|
+
// `test/cms-editor-mirrors.test.ts`, because a duplicate nobody verifies is a latent bug with
|
|
526
|
+
// a comment on it.
|
|
527
|
+
//
|
|
528
|
+
// The server treats an unrecognised value as absent, so a drift here degrades to the default
|
|
529
|
+
// order rather than to an error. That is the failure worth having, and it is still a failure:
|
|
530
|
+
// a sort the editor offers and the server drops is a control that silently does nothing.
|
|
531
|
+
|
|
532
|
+
/** How the media library may be ordered. */
|
|
533
|
+
export const MEDIA_SORTS = ["newest", "oldest", "name", "name_desc", "largest", "smallest"] as const;
|
|
534
|
+
export type MediaSort = (typeof MEDIA_SORTS)[number];
|
|
535
|
+
|
|
536
|
+
/** What each sort is called on screen. */
|
|
537
|
+
export const MEDIA_SORT_LABELS = {
|
|
538
|
+
newest: "Newest first",
|
|
539
|
+
oldest: "Oldest first",
|
|
540
|
+
name: "Name A–Z",
|
|
541
|
+
name_desc: "Name Z–A",
|
|
542
|
+
largest: "Largest first",
|
|
543
|
+
smallest: "Smallest first",
|
|
544
|
+
} satisfies Record<MediaSort, string>;
|
|
545
|
+
|
|
546
|
+
/** The coarse type buckets the library filters by. */
|
|
547
|
+
export const MEDIA_KINDS = ["image", "video", "audio", "document", "other"] as const;
|
|
548
|
+
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
|
549
|
+
|
|
550
|
+
/** …and their labels. Plural, because each names a SET the filter narrows to. */
|
|
551
|
+
export const MEDIA_KIND_LABELS = {
|
|
552
|
+
image: "Images",
|
|
553
|
+
video: "Video",
|
|
554
|
+
audio: "Audio",
|
|
555
|
+
document: "Documents",
|
|
556
|
+
other: "Other",
|
|
557
|
+
} satisfies Record<MediaKind, string>;
|