caracms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library level (M5b Phase 3, Decision 7; the mockup's #library, #library-clips,
|
|
3
|
+
* #library-upload, #library-image, #library-unused, #library-empty, #library-pick,
|
|
4
|
+
* #library-pick-images, #library-picked-alt): `view.level === 'media'`.
|
|
5
|
+
*
|
|
6
|
+
* - **Browse** (Site-wide → Media): images and clips, newest first; Upload… and drops on
|
|
7
|
+
* the level or on the stage (shell/Stage.tsx dims the last page and makes it the big
|
|
8
|
+
* drop target); several files at once, each with real progress; a refused file says
|
|
9
|
+
* why, in red, until the library is left or a slot's picker opens. A tile opens its own
|
|
10
|
+
* level: the picture large on the stage, its facts, where it's used (each a link to the
|
|
11
|
+
* document) and the alt text already used for it.
|
|
12
|
+
* - **Pick** (a slot's Choose… or Replace…, `view.media.pick`): in place of the block's
|
|
13
|
+
* fields, the page left on the stage with the slot ringed (overlays/editor-marks.ts);
|
|
14
|
+
* one click places it (Decision 3's value through `pickMedia`, store.ts → place) and
|
|
15
|
+
* goes back to the field. A list's Add image takes several: each click adds a row in
|
|
16
|
+
* the order picked, and Done goes back, to the first row picked with no alt text (each
|
|
17
|
+
* is asked for in turn). A clip can be picked only once it can play.
|
|
18
|
+
* - **Show unused** (on by default, remembered per browser) shows or hides what no
|
|
19
|
+
* document uses; what was uploaded in this tab always shows. **Nothing is ever deleted.**
|
|
20
|
+
* - Last-known data while a refetch runs; no skeletons.
|
|
21
|
+
* - **M6c G4: Files** (the mockup's #library-files, #library-unused, #file, #link-pick): a
|
|
22
|
+
* tab beside Images and Clips, each file a row (its type tile, name, type · size · date
|
|
23
|
+
* · how many places link to it); a file's own level gives its address (copyable), every
|
|
24
|
+
* document that links to it, and Replace… (FileLevel). A link field's or a button's
|
|
25
|
+
* File… opens the library on Files only (`pick.link`), and one click writes the file's
|
|
26
|
+
* address into the link.
|
|
27
|
+
*/
|
|
28
|
+
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
29
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
30
|
+
import type { MediaPick, MediaView } from '../../editor.ts';
|
|
31
|
+
import { altAskFor, extOf, fmtDur, UploadCard, useDrop } from '../../fields/MediaField.tsx';
|
|
32
|
+
import { when } from '../../live/format.ts';
|
|
33
|
+
import { clipId, clipStill, fileSize, mediaFor, type MediaUpload } from '../../media/store.ts';
|
|
34
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
35
|
+
import type { LibraryClip, LibraryFile, LibraryImage } from '../../../types.ts';
|
|
36
|
+
import { getAtPath } from '../../../../fields.ts';
|
|
37
|
+
import { fileAddress, fileKeyOf, isFileKey } from '../../../../files.ts';
|
|
38
|
+
import { parseMedia } from '../../../../media-value.ts';
|
|
39
|
+
import { FileLevel } from './FileLevel.tsx';
|
|
40
|
+
import { CurrentHead, DocumentsTab, GlobalNotes, LevelTab, RecentStrip, Trail } from '../parts.tsx';
|
|
41
|
+
import { CollectionTab } from './FormLevel.tsx';
|
|
42
|
+
|
|
43
|
+
/** M6c G4: the files the library takes, by extension (the server reads the bytes). */
|
|
44
|
+
export const FILE_ACCEPT = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.zip';
|
|
45
|
+
const ACCEPT = `image/*,video/*,.heic,.heif,${FILE_ACCEPT}`;
|
|
46
|
+
|
|
47
|
+
/** A hidden file input and the button that opens it (the browser's own file dialog). `filesOnly`: a link's picker. */
|
|
48
|
+
export function UploadButton({ className = 'btn sm', children, filesOnly = false }: { className?: string; children?: ReactNode; filesOnly?: boolean }) {
|
|
49
|
+
const editor = useEditor();
|
|
50
|
+
const media = mediaFor(editor);
|
|
51
|
+
const input = useRef<HTMLInputElement>(null);
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<button type="button" className={className} data-act="lib-upload" onClick={() => input.current?.click()}>
|
|
55
|
+
<Icon name="upload" size="sm" />
|
|
56
|
+
{children ?? 'Upload…'}
|
|
57
|
+
</button>
|
|
58
|
+
<input
|
|
59
|
+
ref={input}
|
|
60
|
+
type="file"
|
|
61
|
+
multiple
|
|
62
|
+
hidden
|
|
63
|
+
accept={filesOnly ? FILE_ACCEPT : ACCEPT}
|
|
64
|
+
data-lib-file=""
|
|
65
|
+
onChange={(e) => {
|
|
66
|
+
const files = [...(e.target.files ?? [])];
|
|
67
|
+
e.target.value = '';
|
|
68
|
+
if (files.length) media.upload(files);
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function MediaLevel() {
|
|
76
|
+
const editor = useEditor();
|
|
77
|
+
const view = useStore(editor.view, (v) => v.media ?? null);
|
|
78
|
+
const media = mediaFor(editor);
|
|
79
|
+
// Made before the picker places anything: a gallery with no row open has no slot on screen to make it.
|
|
80
|
+
altAskFor(editor);
|
|
81
|
+
// Read again each time the library opens (the server keeps its listing 60 s); the last list shows meanwhile.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
void media.loadImages();
|
|
84
|
+
void media.loadClips();
|
|
85
|
+
}, [media]);
|
|
86
|
+
const state: MediaView = view ?? { pick: null, item: null, tab: 'images' };
|
|
87
|
+
if (state.item && isFileKey(state.item)) return <FileLevel fileKey={state.item} />;
|
|
88
|
+
if (state.item) return <ItemLevel view={state} />;
|
|
89
|
+
return <LibraryLevel view={state} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const timeZoneOf = (editor: ReturnType<typeof useEditor>) => editor.liveState.get().status?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
93
|
+
|
|
94
|
+
function LibraryLevel({ view }: { view: MediaView }) {
|
|
95
|
+
const editor = useEditor();
|
|
96
|
+
const media = mediaFor(editor);
|
|
97
|
+
const images = useStore(media.images);
|
|
98
|
+
const clips = useStore(media.clips);
|
|
99
|
+
const files = useStore(media.files);
|
|
100
|
+
const uploads = useStore(media.uploads);
|
|
101
|
+
const refused = useStore(media.refused);
|
|
102
|
+
const unused = useStore(media.showUnused);
|
|
103
|
+
const fresh = useStore(media.fresh);
|
|
104
|
+
const session = useStore(editor.doc.state);
|
|
105
|
+
const pick = view.pick;
|
|
106
|
+
const only = pick?.only;
|
|
107
|
+
const linking = !!pick?.link;
|
|
108
|
+
// A slot never lists files (Decision 4); a link lists nothing else (Decision 7).
|
|
109
|
+
const tab = linking ? 'files' : only === 'image' ? 'images' : only === 'video' ? 'clips' : pick && view.tab === 'files' ? 'images' : view.tab;
|
|
110
|
+
const drop = useDrop((files) => media.upload(files));
|
|
111
|
+
// A list's Add image: what this picker has added so far (the rows from the pick's first index on).
|
|
112
|
+
const added = useRef<string[]>([]);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
added.current = [];
|
|
115
|
+
}, [pick?.scope, pick?.multi]);
|
|
116
|
+
|
|
117
|
+
const imgList = images.list ?? [];
|
|
118
|
+
const clipList = clips.list ?? [];
|
|
119
|
+
const fileList = files.list ?? [];
|
|
120
|
+
const total = imgList.length + clipList.length + fileList.length;
|
|
121
|
+
const atPick = pick ? getAtPath(editor.doc.block(pick.scope)?.data, pick.path) : null;
|
|
122
|
+
const current = pick && !linking ? parseMedia(atPick) : null;
|
|
123
|
+
const currentFile = linking ? (fileKeyOf(typeof atPick === 'string' ? atPick.trim() : null)?.key ?? null) : null;
|
|
124
|
+
|
|
125
|
+
const setTab = (next: MediaView['tab']) => editor.view.update({ media: { ...view, tab: next } });
|
|
126
|
+
const back = () => editor.goUp();
|
|
127
|
+
|
|
128
|
+
// The picker's trail is the slot's own, one level deeper (the mockup's pickTrail): a block's slot under its page's
|
|
129
|
+
// Blocks, a form's under its collection, Site Settings' brand slots under Editor branding.
|
|
130
|
+
const kind = session.document.kind;
|
|
131
|
+
const blocks = session.document.blocks.length;
|
|
132
|
+
const head = pick ? (
|
|
133
|
+
<Trail>
|
|
134
|
+
<DocumentsTab />
|
|
135
|
+
{kind === 'sections' ? (
|
|
136
|
+
<LevelTab name={session.document.title || session.entry.label} kind="Blocks" count={`${blocks} block${blocks === 1 ? '' : 's'}`} onClick={() => editor.goTo('document')} />
|
|
137
|
+
) : kind === 'fields' || kind === 'shared' ? (
|
|
138
|
+
<CollectionTab name={session.ref.collection} />
|
|
139
|
+
) : null}
|
|
140
|
+
{kind === 'settings' ? <LevelTab name="Site Settings" kind="Editor branding" onClick={back} /> : <LevelTab name={pick.owner} kind="Fields" onClick={back} />}
|
|
141
|
+
<CurrentHead
|
|
142
|
+
back={{ label: `Back to ${pick.owner}`, onClick: back }}
|
|
143
|
+
crumb={pick.owner}
|
|
144
|
+
title={linking ? 'Choose a file' : only === 'image' ? 'Choose an image' : only === 'video' ? 'Choose a clip' : 'Choose an image or a clip'}
|
|
145
|
+
sub={pick.label}
|
|
146
|
+
menu={<UploadButton filesOnly={linking} />}
|
|
147
|
+
/>
|
|
148
|
+
</Trail>
|
|
149
|
+
) : (
|
|
150
|
+
<Trail>
|
|
151
|
+
<DocumentsTab />
|
|
152
|
+
<CurrentHead
|
|
153
|
+
back={{ label: 'Back to documents', onClick: () => editor.goTo('documents') }}
|
|
154
|
+
title="Media"
|
|
155
|
+
chips={images.list || clips.list ? <span className="n-chip">{total}</span> : null}
|
|
156
|
+
sub="Images, clips and files, for every page"
|
|
157
|
+
menu={<UploadButton />}
|
|
158
|
+
/>
|
|
159
|
+
</Trail>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const place = (entry: LibraryImage | LibraryClip) => {
|
|
163
|
+
if (!pick) return;
|
|
164
|
+
const id = 'key' in entry ? entry.key : clipId(entry);
|
|
165
|
+
if (pick.multi && added.current.includes(id)) return;
|
|
166
|
+
if (!media.place(pick, entry)) return;
|
|
167
|
+
if (pick.multi) {
|
|
168
|
+
added.current = [...added.current, id];
|
|
169
|
+
const top = pick.path.slice(0, pick.path.lastIndexOf('.'));
|
|
170
|
+
const next = Number(pick.path.slice(pick.path.lastIndexOf('.') + 1)) + 1;
|
|
171
|
+
const nextPick: MediaPick = { ...pick, path: `${top}.${next}`, label: pick.label.replace(/\d+$/, String(next + 1)) };
|
|
172
|
+
editor.view.update({ media: { ...view, pick: nextPick } });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
editor.view.update({ level: pick.back, media: null, field: pick.path, picked: false });
|
|
176
|
+
};
|
|
177
|
+
const done = () => {
|
|
178
|
+
if (!pick) return;
|
|
179
|
+
const top = pick.path.slice(0, pick.path.lastIndexOf('.'));
|
|
180
|
+
// The first image picked with no alt text opens its row, asking for it (the rest are asked in turn: MediaField.tsx).
|
|
181
|
+
const ask = altAskFor(editor).get();
|
|
182
|
+
const first = ask && ask.scope === pick.scope && ask.path.startsWith(`${top}.`) ? ask.path : null;
|
|
183
|
+
editor.view.update({ level: pick.back, media: null, field: first ?? (top || pick.path), picked: false });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const placeFile = (file: LibraryFile) => {
|
|
187
|
+
if (!pick || editor.access.get().readOnly) return;
|
|
188
|
+
editor.doc.setField(pick.scope, pick.path, fileAddress(file.key));
|
|
189
|
+
editor.announce(`${file.name} is in the link`);
|
|
190
|
+
editor.view.update({ level: pick.back, media: null, field: pick.path.split('.')[0]!, picked: false });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const unset = tab === 'clips' ? clips.unset : images.unset;
|
|
194
|
+
let body: ReactNode;
|
|
195
|
+
if (images.unset && (tab !== 'clips' || !pick)) {
|
|
196
|
+
body = <UnsetNote sentence={images.unset.error} />;
|
|
197
|
+
} else {
|
|
198
|
+
const ups = uploads.filter((u) => !u.slot && (tab === 'clips' ? u.kind === 'clip' : tab === 'files' ? u.kind === 'file' : u.kind === 'image'));
|
|
199
|
+
const empty = images.list !== null && clips.list !== null && total === 0 && uploads.length === 0;
|
|
200
|
+
const used = (x: { usedIn: unknown[] }) => x.usedIn.length > 0;
|
|
201
|
+
const list: (LibraryImage | LibraryClip | LibraryFile)[] = tab === 'images' ? imgList : tab === 'files' ? fileList : clipList;
|
|
202
|
+
const idOf = (x: LibraryImage | LibraryClip | LibraryFile) => ('key' in x ? x.key : clipId(x));
|
|
203
|
+
// A link's picker lists every file: picking one is how it becomes used.
|
|
204
|
+
const shown = list.filter((x) => unused || linking || used(x) || fresh.has(idOf(x)) || ('ready' in x && !x.ready));
|
|
205
|
+
const hidden = list.length - shown.length;
|
|
206
|
+
const tabs = linking ? (
|
|
207
|
+
<span className="only">
|
|
208
|
+
Files <span className="n">· {fileList.length}</span>
|
|
209
|
+
</span>
|
|
210
|
+
) : only ? (
|
|
211
|
+
<span className="only">
|
|
212
|
+
{only === 'image' ? 'Images' : 'Clips'} <span className="n">· {only === 'image' ? imgList.length : clipList.length}</span>
|
|
213
|
+
</span>
|
|
214
|
+
) : (
|
|
215
|
+
<div className="mini-seg" role="radiogroup" aria-label="Show">
|
|
216
|
+
<button type="button" role="radio" aria-checked={tab === 'images'} data-v="images" onClick={() => setTab('images')}>
|
|
217
|
+
Images <span className="n">{images.list ? imgList.length : ''}</span>
|
|
218
|
+
</button>
|
|
219
|
+
<button type="button" role="radio" aria-checked={tab === 'clips'} data-v="clips" onClick={() => setTab('clips')}>
|
|
220
|
+
Clips <span className="n">{clips.list ? clipList.length : ''}</span>
|
|
221
|
+
</button>
|
|
222
|
+
{pick ? null : (
|
|
223
|
+
<button type="button" role="radio" aria-checked={tab === 'files'} data-v="files" onClick={() => setTab('files')}>
|
|
224
|
+
Files <span className="n">{files.list ? fileList.length : ''}</span>
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
body = (
|
|
230
|
+
<>
|
|
231
|
+
{refused.length ? (
|
|
232
|
+
<div className="refused" role="alert">
|
|
233
|
+
{refused.map((r) => (
|
|
234
|
+
<div key={r.id} className="note danger has-x">
|
|
235
|
+
<Icon name="warn" />
|
|
236
|
+
<div>
|
|
237
|
+
<b>{r.name}</b>
|
|
238
|
+
<p>{r.message}</p>
|
|
239
|
+
</div>
|
|
240
|
+
<button type="button" className="ib" aria-label="Dismiss" onClick={() => media.dismissRefused(r.id)}>
|
|
241
|
+
<Icon name="x" size="sm" />
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
) : null}
|
|
247
|
+
{empty ? (
|
|
248
|
+
<div className="empty" style={{ padding: 'var(--space-4)' }} data-library-empty="">
|
|
249
|
+
<b>Nothing in the library yet</b>
|
|
250
|
+
<span>Upload or drop images, clips or files.</span>
|
|
251
|
+
<UploadButton className="btn ink" filesOnly={linking}>
|
|
252
|
+
Upload…
|
|
253
|
+
</UploadButton>
|
|
254
|
+
</div>
|
|
255
|
+
) : (
|
|
256
|
+
<>
|
|
257
|
+
<div className="lib-bar">
|
|
258
|
+
{tabs}
|
|
259
|
+
{linking ? null : (
|
|
260
|
+
<button type="button" className="switch sm" role="switch" aria-checked={unused} data-act="lib-unused" onClick={() => media.setShowUnused(!unused)}>
|
|
261
|
+
<span className="track" />
|
|
262
|
+
<span>
|
|
263
|
+
Show unused{hidden ? <span className="n"> · {hidden} hidden</span> : null}
|
|
264
|
+
</span>
|
|
265
|
+
</button>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
{unset ? (
|
|
269
|
+
<UnsetNote sentence={unset.error} />
|
|
270
|
+
) : tab === 'files' && (shown.length || ups.length) ? (
|
|
271
|
+
<div className="flist" data-flist="">
|
|
272
|
+
{ups.map((u) => (
|
|
273
|
+
<FileUploadRow key={u.id} upload={u} />
|
|
274
|
+
))}
|
|
275
|
+
{(shown as LibraryFile[]).map((x) => (
|
|
276
|
+
<FileRow
|
|
277
|
+
key={x.key}
|
|
278
|
+
file={x}
|
|
279
|
+
current={currentFile === x.key}
|
|
280
|
+
fresh={fresh.has(x.key)}
|
|
281
|
+
onClick={() => (linking ? placeFile(x) : editor.openMedia({ item: x.key, tab: 'files' }))}
|
|
282
|
+
/>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
) : tab === 'files' ? (
|
|
286
|
+
files.list ? (
|
|
287
|
+
<p className="no-hits">
|
|
288
|
+
{fileList.length ? (
|
|
289
|
+
<>
|
|
290
|
+
<b>No files linked from anywhere yet</b>
|
|
291
|
+
Turn on Show unused to see every file in the library.
|
|
292
|
+
</>
|
|
293
|
+
) : (
|
|
294
|
+
<>
|
|
295
|
+
<b>No files yet</b>
|
|
296
|
+
Upload or drop a file.
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</p>
|
|
300
|
+
) : null
|
|
301
|
+
) : shown.length || ups.length ? (
|
|
302
|
+
<div className={`lgrid${tab === 'clips' ? ' clips' : ''}`} data-lgrid={tab}>
|
|
303
|
+
{ups.map((u) => (u.phase === 'failed' ? <FailedTile key={u.id} upload={u} /> : <UploadTile key={u.id} upload={u} />))}
|
|
304
|
+
{(shown as (LibraryImage | LibraryClip)[]).map((x) =>
|
|
305
|
+
'key' in x ? (
|
|
306
|
+
<ImageTile
|
|
307
|
+
key={x.key}
|
|
308
|
+
image={x}
|
|
309
|
+
pick={!!pick}
|
|
310
|
+
current={(!!current && 'key' in current && current.key === x.key) || (!!pick?.multi && added.current.includes(x.key))}
|
|
311
|
+
fresh={fresh.has(x.key)}
|
|
312
|
+
onClick={() => (pick ? place(x) : editor.openMedia({ item: x.key, tab: 'images' }))}
|
|
313
|
+
/>
|
|
314
|
+
) : (
|
|
315
|
+
<ClipTile
|
|
316
|
+
key={x.id}
|
|
317
|
+
clip={x}
|
|
318
|
+
pick={!!pick}
|
|
319
|
+
current={(!!current && 'video' in current && current.video === x.playbackId) || (!!pick?.multi && added.current.includes(clipId(x)))}
|
|
320
|
+
fresh={fresh.has(clipId(x))}
|
|
321
|
+
onClick={() => (pick ? place(x) : editor.openMedia({ item: clipId(x), tab: 'clips' }))}
|
|
322
|
+
/>
|
|
323
|
+
),
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
) : list.length || (tab === 'images' ? images.list : clips.list) ? (
|
|
327
|
+
<p className="no-hits">
|
|
328
|
+
<b>{tab === 'images' ? 'No images' : 'No clips'} in use yet</b>
|
|
329
|
+
Turn on Show unused to see everything in the library.
|
|
330
|
+
</p>
|
|
331
|
+
) : null}
|
|
332
|
+
</>
|
|
333
|
+
)}
|
|
334
|
+
{pick?.multi ? (
|
|
335
|
+
<div className="lib-done" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginTop: 'var(--space-4)' }}>
|
|
336
|
+
<button type="button" className="btn ink" data-act="pick-done" onClick={done}>
|
|
337
|
+
Done
|
|
338
|
+
</button>
|
|
339
|
+
<span className="kb-hint" style={{ margin: 0 }}>
|
|
340
|
+
Each one you click is added to the {pick.owner.toLowerCase()}, in order.
|
|
341
|
+
</span>
|
|
342
|
+
</div>
|
|
343
|
+
) : null}
|
|
344
|
+
</>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<>
|
|
350
|
+
{head}
|
|
351
|
+
<div className={`side-body${drop.over ? ' is-over' : ''}`} data-drop-lib="" data-media-mode={pick ? 'pick' : 'browse'} {...drop.props}>
|
|
352
|
+
<GlobalNotes />
|
|
353
|
+
{body}
|
|
354
|
+
</div>
|
|
355
|
+
<RecentStrip />
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function UnsetNote({ sentence }: { sentence: string }) {
|
|
361
|
+
return (
|
|
362
|
+
<div className="note tight" data-media-unset="">
|
|
363
|
+
<Icon name="media" />
|
|
364
|
+
<div>
|
|
365
|
+
<b>Media isn't set up on this server yet</b>
|
|
366
|
+
<p>{sentence}</p>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** "14 Oct" this year, "4 Mar 2025" before, in the site's time zone. */
|
|
373
|
+
export function rowDate(iso: string | null, zone: string): string {
|
|
374
|
+
if (!iso) return '';
|
|
375
|
+
const date = new Date(iso);
|
|
376
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
377
|
+
const year = (d: Date) => new Intl.DateTimeFormat('en-GB', { year: 'numeric', timeZone: zone }).format(d);
|
|
378
|
+
const sameYear = year(date) === year(new Date());
|
|
379
|
+
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', ...(sameYear ? {} : { year: 'numeric' }), timeZone: zone }).format(date);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The type tile: a page with its corner folded (neutral for every type); the large one carries the label. */
|
|
383
|
+
export function FileTile({ type, size }: { type?: string; size?: 'sm' | 'lg' }) {
|
|
384
|
+
return (
|
|
385
|
+
<span className={`ftype${size ? ` ${size}` : ''}`} aria-hidden="true">
|
|
386
|
+
{size === 'lg' ? type : null}
|
|
387
|
+
</span>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A file's row in the library (the mockup's .frow): the tile, the name, then type · size · date · use. */
|
|
392
|
+
export function FileRow(props: { file: LibraryFile; current: boolean; fresh: boolean; onClick: () => void }) {
|
|
393
|
+
const editor = useEditor();
|
|
394
|
+
const { file: x, current } = props;
|
|
395
|
+
const n = x.usedIn.length;
|
|
396
|
+
const use = current ? 'In this link' : props.fresh ? null : n ? `Linked from ${n}` : 'Unused';
|
|
397
|
+
return (
|
|
398
|
+
<button type="button" className={`frow${current ? ' is-current' : ''}`} data-file={x.key} title={`${x.name} · ${x.type}`} aria-current={current ? 'true' : undefined} onClick={props.onClick}>
|
|
399
|
+
<FileTile />
|
|
400
|
+
<span className="ft">
|
|
401
|
+
<b>{x.name}</b>
|
|
402
|
+
<small>
|
|
403
|
+
<span className="ty">{x.type}</span>
|
|
404
|
+
{x.bytes !== null ? ` · ${fileSize(x.bytes)}` : ''}
|
|
405
|
+
{x.uploadedAt ? ` · ${rowDate(x.uploadedAt, timeZoneOf(editor))}` : ''} · {use ?? <span className="new">Just uploaded</span>}
|
|
406
|
+
</small>
|
|
407
|
+
</span>
|
|
408
|
+
{current ? (
|
|
409
|
+
<span className="pcheck">
|
|
410
|
+
<Icon name="check" size="xs" />
|
|
411
|
+
</span>
|
|
412
|
+
) : null}
|
|
413
|
+
</button>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A file on its way up: real progress, the row's shape kept. */
|
|
418
|
+
export function FileUploadRow({ upload: u, className = '' }: { upload: MediaUpload; className?: string }) {
|
|
419
|
+
return (
|
|
420
|
+
<div className={`frow${className ? ` ${className}` : ''}`} role="status" aria-label={`${u.name}, uploading, ${u.pct}%`} data-upload={u.id}>
|
|
421
|
+
<FileTile />
|
|
422
|
+
<span className="ft">
|
|
423
|
+
<b>{u.name}</b>
|
|
424
|
+
<small>
|
|
425
|
+
{fileSize(u.bytes)} · {u.pct ? `Uploading · ${u.pct}%` : 'Waiting…'}
|
|
426
|
+
</small>
|
|
427
|
+
<span className="mbar">
|
|
428
|
+
<i style={{ ['--p' as string]: `${u.pct}%` }} />
|
|
429
|
+
</span>
|
|
430
|
+
</span>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function ImageTile(props: { image: LibraryImage; pick: boolean; current: boolean; fresh: boolean; onClick: () => void }) {
|
|
436
|
+
const { image: x, current } = props;
|
|
437
|
+
const n = x.usedIn.length;
|
|
438
|
+
return (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
className={`mtile${current ? ' is-current' : ''}`}
|
|
442
|
+
data-key={x.key}
|
|
443
|
+
title={`${x.name}${x.w && x.h ? ` · ${x.w} × ${x.h}` : ''}`}
|
|
444
|
+
aria-current={current ? 'true' : undefined}
|
|
445
|
+
onClick={props.onClick}
|
|
446
|
+
>
|
|
447
|
+
<span className="ph">
|
|
448
|
+
<img className="ph-img" src={x.thumbnail} srcSet={`${x.thumbnail} 1x, ${x.thumbnail2x} 2x`} alt="" loading="lazy" />
|
|
449
|
+
</span>
|
|
450
|
+
{current ? (
|
|
451
|
+
<span className="pcheck">
|
|
452
|
+
<Icon name="check" size="xs" />
|
|
453
|
+
</span>
|
|
454
|
+
) : null}
|
|
455
|
+
<span className="nm">{x.name}</span>
|
|
456
|
+
{props.fresh ? <span className="mt new">Just added</span> : <span className="mt">{current && props.pick ? 'In this slot' : n ? `Used on ${n}` : 'Unused'}</span>}
|
|
457
|
+
</button>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function ClipTile(props: { clip: LibraryClip; pick: boolean; current: boolean; fresh: boolean; onClick: () => void }) {
|
|
462
|
+
const { clip: c, current } = props;
|
|
463
|
+
const title = c.title ?? 'A clip';
|
|
464
|
+
if (c.errored) {
|
|
465
|
+
return (
|
|
466
|
+
<div className="mfail" role="group" aria-label={`${title}, failed`} data-clip={c.id}>
|
|
467
|
+
<span className="ph pending">
|
|
468
|
+
<Icon name="warn" />
|
|
469
|
+
</span>
|
|
470
|
+
<div>
|
|
471
|
+
<b>{title}</b>
|
|
472
|
+
<p>{c.error}</p>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const proc = !c.ready;
|
|
478
|
+
const n = c.usedIn.length;
|
|
479
|
+
const meta = proc ? 'Processing: under a minute' : props.fresh ? 'Just added' : current && props.pick ? 'In this slot' : n ? `Used on ${n}` : 'Unused';
|
|
480
|
+
return (
|
|
481
|
+
<button
|
|
482
|
+
type="button"
|
|
483
|
+
className={`mtile${current ? ' is-current' : ''}`}
|
|
484
|
+
data-clip={c.id}
|
|
485
|
+
disabled={proc}
|
|
486
|
+
title={proc ? (props.pick ? "It can be chosen once it's ready to play" : 'Mux is getting it ready to play') : `${title} · ${fmtDur(c.duration)}`}
|
|
487
|
+
aria-current={current ? 'true' : undefined}
|
|
488
|
+
onClick={props.onClick}
|
|
489
|
+
>
|
|
490
|
+
{proc ? (
|
|
491
|
+
<span className="ph pending">
|
|
492
|
+
<Icon name="film" />
|
|
493
|
+
<span className="mbar busy">
|
|
494
|
+
<i />
|
|
495
|
+
</span>
|
|
496
|
+
</span>
|
|
497
|
+
) : (
|
|
498
|
+
<span className="ph">
|
|
499
|
+
{c.playbackId ? <img className="ph-img" src={clipStill(c.playbackId, 0)} alt="" loading="lazy" /> : null}
|
|
500
|
+
<span className="pbadge">{fmtDur(c.duration)}</span>
|
|
501
|
+
</span>
|
|
502
|
+
)}
|
|
503
|
+
{current ? (
|
|
504
|
+
<span className="pcheck">
|
|
505
|
+
<Icon name="check" size="xs" />
|
|
506
|
+
</span>
|
|
507
|
+
) : null}
|
|
508
|
+
<span className="nm">{title}</span>
|
|
509
|
+
<span className={`mt${props.fresh && !proc ? ' new' : ''}`}>{meta}</span>
|
|
510
|
+
</button>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function UploadTile({ upload: u }: { upload: MediaUpload }) {
|
|
515
|
+
const proc = u.phase === 'proc';
|
|
516
|
+
return (
|
|
517
|
+
<div className="mtile" role="status" aria-label={`${u.name}: ${proc ? 'processing' : `uploading, ${u.pct}%`}`} data-upload={u.id}>
|
|
518
|
+
{u.preview ? (
|
|
519
|
+
<span className="ph">
|
|
520
|
+
<span style={{ position: 'absolute', inset: 0, opacity: 0.55 }}>
|
|
521
|
+
<img className="ph-img" src={u.preview} alt="" />
|
|
522
|
+
</span>
|
|
523
|
+
<span className="mbar">
|
|
524
|
+
<i style={{ ['--p' as string]: `${u.pct}%` }} />
|
|
525
|
+
</span>
|
|
526
|
+
</span>
|
|
527
|
+
) : (
|
|
528
|
+
<span className="ph pending">
|
|
529
|
+
{u.kind === 'clip' ? <Icon name="film" /> : extOf(u.name)}
|
|
530
|
+
<span className={`mbar${proc ? ' busy' : ''}`} style={{ ['--p' as string]: `${u.pct}%` }}>
|
|
531
|
+
<i />
|
|
532
|
+
</span>
|
|
533
|
+
</span>
|
|
534
|
+
)}
|
|
535
|
+
<span className="nm">{u.name}</span>
|
|
536
|
+
<span className="mt">{proc ? 'Processing: under a minute' : u.pct ? `Uploading${u.kind === 'clip' ? ' to Mux' : ''} · ${u.pct}%` : 'Waiting…'}</span>
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function FailedTile({ upload: u }: { upload: MediaUpload }) {
|
|
542
|
+
const editor = useEditor();
|
|
543
|
+
const media = mediaFor(editor);
|
|
544
|
+
return (
|
|
545
|
+
<div className="mfail" role="group" aria-label={`${u.name}, failed`}>
|
|
546
|
+
<span className="ph pending">
|
|
547
|
+
<Icon name="warn" />
|
|
548
|
+
</span>
|
|
549
|
+
<div>
|
|
550
|
+
<b>{u.name}</b>
|
|
551
|
+
<p>{u.error}</p>
|
|
552
|
+
</div>
|
|
553
|
+
<button type="button" className="ib" aria-label="Dismiss" onClick={() => media.dismissUpload(u.id)} style={{ gridColumn: 2, justifySelf: 'end' }}>
|
|
554
|
+
<Icon name="x" size="sm" />
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** An image's (or a clip's) own level: the picture, its facts, where it's used, the alt text already used for it. */
|
|
561
|
+
function ItemLevel({ view }: { view: MediaView }) {
|
|
562
|
+
const editor = useEditor();
|
|
563
|
+
const media = mediaFor(editor);
|
|
564
|
+
const images = useStore(media.images, (l) => l.list);
|
|
565
|
+
const clips = useStore(media.clips, (l) => l.list);
|
|
566
|
+
const documents = useStore(editor.documents);
|
|
567
|
+
const item = view.item!;
|
|
568
|
+
const clip = item.startsWith('clip:');
|
|
569
|
+
const x = useMemo(() => (clip ? (clips ?? []).find((c) => clipId(c) === item) : (images ?? []).find((i) => i.key === item)) ?? null, [clip, clips, images, item]);
|
|
570
|
+
const count = clip ? (clips?.length ?? 0) : (images?.length ?? 0);
|
|
571
|
+
const back = () => editor.goUp();
|
|
572
|
+
if (!x) {
|
|
573
|
+
return (
|
|
574
|
+
<>
|
|
575
|
+
<Trail>
|
|
576
|
+
<DocumentsTab />
|
|
577
|
+
<CurrentHead back={{ label: 'Back to Media', onClick: back }} crumb="Media" title={clip ? 'A clip' : (item.split('/').pop() ?? item)} />
|
|
578
|
+
</Trail>
|
|
579
|
+
<div className="side-body">
|
|
580
|
+
<GlobalNotes />
|
|
581
|
+
</div>
|
|
582
|
+
<RecentStrip />
|
|
583
|
+
</>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const name = 'key' in x ? x.name : (x.title ?? 'A clip');
|
|
587
|
+
const size = x.w && x.h ? `${x.w} × ${x.h}` : '';
|
|
588
|
+
const uploadedAt = 'key' in x ? x.uploadedAt : x.createdAt;
|
|
589
|
+
const zone = timeZoneOf(editor);
|
|
590
|
+
const uses = x.usedIn;
|
|
591
|
+
const iconOf = (collection: string) => (collection === 'settings' ? 'gear' : collection === 'shared' ? 'shared' : editor.site.collections.find((c) => c.name === collection)?.kind === 'fields' ? 'case' : 'page');
|
|
592
|
+
return (
|
|
593
|
+
<>
|
|
594
|
+
<Trail>
|
|
595
|
+
<DocumentsTab />
|
|
596
|
+
<LevelTab name="Media" kind={clip ? 'Clips' : 'Images'} count={String(count)} onClick={back} />
|
|
597
|
+
<CurrentHead back={{ label: 'Back to Media', onClick: back }} crumb="Media" title={name} sub={`${size}${size ? ' · ' : ''}${'key' in x ? extOf(x.key) : fmtDur(x.duration)}`} />
|
|
598
|
+
</Trail>
|
|
599
|
+
<div className="side-body" data-media-item={item}>
|
|
600
|
+
<GlobalNotes />
|
|
601
|
+
<div className="mdetail">
|
|
602
|
+
<span className="ph" style={{ ['--r' as string]: x.w && x.h ? (x.w / x.h).toFixed(4) : '1.5' }}>
|
|
603
|
+
{'key' in x ? <img className="ph-img" src={x.thumbnail2x} alt="" /> : x.playbackId ? <img className="ph-img" src={clipStill(x.playbackId, 0, 640)} alt="" /> : null}
|
|
604
|
+
</span>
|
|
605
|
+
</div>
|
|
606
|
+
<dl className="kv">
|
|
607
|
+
<dt>Uploaded</dt>
|
|
608
|
+
<dd>
|
|
609
|
+
{uploadedAt ? when(uploadedAt, zone, new Date().toISOString()) : 'Not known'}
|
|
610
|
+
{'key' in x && x.by && x.by !== 'setup' ? ` by ${x.by}` : ''}
|
|
611
|
+
</dd>
|
|
612
|
+
{size ? (
|
|
613
|
+
<>
|
|
614
|
+
<dt>Size</dt>
|
|
615
|
+
<dd>
|
|
616
|
+
{size} pixels{'key' in x ? '' : `, ${fmtDur(x.duration)}`}
|
|
617
|
+
</dd>
|
|
618
|
+
</>
|
|
619
|
+
) : null}
|
|
620
|
+
<dt>{clip ? 'Playback ID' : 'Key'}</dt>
|
|
621
|
+
<dd>
|
|
622
|
+
<span className="mono">{'key' in x ? x.key : (x.playbackId ?? '')}</span>
|
|
623
|
+
</dd>
|
|
624
|
+
</dl>
|
|
625
|
+
<p className="sec-h">{uses.length ? `Used on ${uses.length}` : 'Not used anywhere'}</p>
|
|
626
|
+
{uses.length ? (
|
|
627
|
+
<div className="uses">
|
|
628
|
+
{uses.map((u) => (
|
|
629
|
+
<button key={`${u.collection}/${u.path}`} type="button" className="drow" data-use={`${u.collection}/${u.path}`} onClick={() => void editor.openDocument({ collection: u.collection, path: u.path })}>
|
|
630
|
+
<Icon name={iconOf(u.collection)} />
|
|
631
|
+
<span className="nm">{documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.title}</span>
|
|
632
|
+
<Icon name="chev-r" size="sm" />
|
|
633
|
+
</button>
|
|
634
|
+
))}
|
|
635
|
+
</div>
|
|
636
|
+
) : (
|
|
637
|
+
<p className="field-help">Not used anywhere.</p>
|
|
638
|
+
)}
|
|
639
|
+
{'key' in x ? (
|
|
640
|
+
<>
|
|
641
|
+
<p className="sec-h">Alt text already used for it</p>
|
|
642
|
+
{x.alt ? (
|
|
643
|
+
<div className="alt-used">
|
|
644
|
+
<q>{x.alt}</q>
|
|
645
|
+
</div>
|
|
646
|
+
) : (
|
|
647
|
+
<div className="alt-used">
|
|
648
|
+
<small style={{ margin: 0 }}>None yet.</small>
|
|
649
|
+
</div>
|
|
650
|
+
)}
|
|
651
|
+
</>
|
|
652
|
+
) : null}
|
|
653
|
+
</div>
|
|
654
|
+
<RecentStrip />
|
|
655
|
+
</>
|
|
656
|
+
);
|
|
657
|
+
}
|