cursedbelt 3.0.2 โ 4.0.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/dist/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import './mediaGallery.css';
|
|
3
|
+
/*
|
|
4
|
+
* โโ ๐ด The toolbar's icons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
5
|
+
*
|
|
6
|
+
* Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like
|
|
7
|
+
* collections uses I want to make the toolbar look more professional. Make it have icons
|
|
8
|
+
* only, instead of words in the strip of tools where have things like pin, stop at the end,
|
|
9
|
+
* and turn video. Hovering over the tooltip can show the words."*
|
|
10
|
+
*
|
|
11
|
+
* `lucide-react`, not the ๐ โณ ๐ผ โ these pills carried until 2026-09-16. An emoji is drawn
|
|
12
|
+
* by whichever font the operating system picked, at whatever optical size and weight that
|
|
13
|
+
* font chose, in colour on some platforms and monochrome on others โ so the one thing a
|
|
14
|
+
* toolbar of them cannot look is designed, which is the opposite of what was asked for. The
|
|
15
|
+
* package is already a dependency (`DropdownMenu`, `PlayerStrip`, `icons.tsx`), so this adds
|
|
16
|
+
* no weight and no second icon vocabulary.
|
|
17
|
+
*
|
|
18
|
+
* ๐ด Three rules every conversion below keeps, because a visual change is exactly where each
|
|
19
|
+
* is silently dropped:
|
|
20
|
+
*
|
|
21
|
+
* 1. **The accessible name survives.** Every icon-only pill carries an `aria-label`, and
|
|
22
|
+
* the glyph itself is `aria-hidden`. The words did double duty as the name.
|
|
23
|
+
* 2. **The 44px tap floor survives.** The hit target is the pill, not the glyph โ see
|
|
24
|
+
* `.cbgd-order-btn.is-icon` in `denseGallery.css`, measured by
|
|
25
|
+
* `e2e/touch-targets.spec.ts` at a real coarse-pointer viewport.
|
|
26
|
+
* 3. **A word that was a VALUE keeps a home.** `๐ Pin` vs `๐ Pinned` and the five
|
|
27
|
+
* end-behaviour labels were not names, they were readouts; an icon identical in both
|
|
28
|
+
* states deletes information. They move into the pressed state, the accessible name and
|
|
29
|
+
* the tooltip together โ `MediaGallery.spec.tsx` asserts each one.
|
|
30
|
+
*
|
|
31
|
+
* What stays a word: the dwell READING (`5s`), the selection count, the sort pills, and the
|
|
32
|
+
* inline rename form's Save/Cancel. Those are values you read or a form's action row
|
|
33
|
+
* (`DESIGN.md` ยง6), not tools in the strip.
|
|
34
|
+
*/
|
|
35
|
+
import { ArrowLeft, ArrowRightToLine, Camera, Crop, Download, Image as ImageIcon, LayoutGrid, ListVideo, Pencil, Pin, Play, Repeat, Repeat1, RotateCw, Rows3, Scissors, Shuffle, SkipForward, SquareCheck, Sun, Tags, Timer, X, } from 'lucide-react';
|
|
3
36
|
import { formatBytes } from 'cwip/format';
|
|
4
37
|
import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
5
38
|
import { CATEGORY_LABEL, FileTypeIcon } from '../components/FileTypeIcon';
|
|
@@ -11,8 +44,8 @@ import { HoverScrubVideoThumb } from '../media/HoverScrubVideoThumb';
|
|
|
11
44
|
import { isPlayPauseSpace } from '../media/playerSpaceKey';
|
|
12
45
|
import { LIGHT_RANGE, lightFilter, NO_EDIT, NO_LIGHT, isLightAdjusted, previewTransform, swapsAxes, } from '../media/pictureEdit';
|
|
13
46
|
import { GALLERY_ORDER_OPTIONS, filterGalleryItems, galleryItemCategory, galleryKindOptions, galleryTags, isMarkdownFile, playsWithSpace, snapsToStage, sortGalleryItems, } from './mediaGalleryModel';
|
|
14
|
-
import { IMAGE_DWELL_OPTIONS, PLAYBACK_END_LABELS, nextPlaybackEnd, readImageDwell, readPinStage, readPlaybackEnd, readThumbAutoplay, writeImageDwell, writePinStage, writePlaybackEnd, writeThumbAutoplay, readViewMode, writeViewMode, } from './playbackPreferences';
|
|
15
|
-
import { GalleryTable } from './GalleryTable';
|
|
47
|
+
import { IMAGE_DWELL_OPTIONS, PLAYBACK_END_LABELS, nextPlaybackEnd, readImageDwell, readPinStage, readPlaybackEnd, readSessionVolume, readThumbAutoplay, STAGE_VOLUME_KEY, writeImageDwell, writeSessionVolume, writePinStage, writePlaybackEnd, writeThumbAutoplay, readViewMode, writeViewMode, } from './playbackPreferences';
|
|
48
|
+
import { GalleryTable, durationLabel } from './GalleryTable';
|
|
16
49
|
import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun';
|
|
17
50
|
import { createdLabel, ThumbImg, useItemMedia } from './galleryItemMedia';
|
|
18
51
|
import { Tooltip } from '../components/Tooltip';
|
|
@@ -36,11 +69,77 @@ const LazyFrameGrabber = React.lazy(() => import('../media/FrameGrabber').then((
|
|
|
36
69
|
// The same four gestures for FOOTAGE โ a separate dialog from the picture editor because a
|
|
37
70
|
// video's turn can only ever be stored, never baked. See `VideoTurner`'s header.
|
|
38
71
|
const LazyVideoTurner = React.lazy(() => import('../media/VideoTurner').then((m) => ({ default: m.VideoTurner })));
|
|
72
|
+
/**
|
|
73
|
+
* The turn dialog, over the item's RESOLVED media โ never over the raw row.
|
|
74
|
+
*
|
|
75
|
+
* โโ ๐ด The black box, and where it came from โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
76
|
+
* Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
|
|
77
|
+
* screen. It should show me the video so I can see how the turn impacts it."*
|
|
78
|
+
*
|
|
79
|
+
* The dialog was mounted as `src={item.streamUrl ?? item.url ?? ''}` โ the fields ON THE ROW.
|
|
80
|
+
* But a gallery item is allowed to carry no URL at all: {@link MediaGalleryItem.resolveMedia}
|
|
81
|
+
* is the documented path for signed URLs and for bytes that become an object URL, and every
|
|
82
|
+
* other surface in this file reads an item through {@link useItemMedia}, which merges that
|
|
83
|
+
* resolver's answer into the row's own fields and caches it per id. So on any app that signs
|
|
84
|
+
* its URLs โ which is the app that reported this โ both fields were `undefined`, the dialog
|
|
85
|
+
* mounted a `<video>` with no `src` and no `poster`, and an element with nothing to load never
|
|
86
|
+
* even fires `error`: it paints black, forever, silently. Reproduced in
|
|
87
|
+
* `MediaGallery.spec.tsx`'s "the dialog is handed the RESOLVED source" before this existed.
|
|
88
|
+
*
|
|
89
|
+
* ๐ด It shares the gallery's own cache rather than calling the resolver again. A second cache
|
|
90
|
+
* would re-sign the URL every time the dialog opened, which is the cost `galleryItemMedia.tsx`
|
|
91
|
+
* was extracted to stop.
|
|
92
|
+
*/
|
|
93
|
+
function TurnVideoDialog({ item, cache, onClose, onSave, }) {
|
|
94
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
95
|
+
// `streamUrl ?? url`, the same preference the stage and the frame grabber make: a plain `url`
|
|
96
|
+
// is not always a streamable source, and a turner given the wrong one shows a black box.
|
|
97
|
+
const src = media.streamUrl ?? media.url ?? '';
|
|
98
|
+
const poster = media.posterUrl ?? media.thumbnailUrl;
|
|
99
|
+
return (_jsx(LazyVideoTurner, { src: src, ...(poster ? { posterUrl: poster } : {}),
|
|
100
|
+
// So an unresolved source reads as "still finding it" for the moment it takes, rather
|
|
101
|
+
// than flashing the "cannot show it" reason at somebody whose film is on its way.
|
|
102
|
+
resolving: resolving, name: item.name, orientation: item.orientation, onClose: onClose, onSave: onSave }));
|
|
103
|
+
}
|
|
39
104
|
// Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
|
|
40
105
|
// has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
|
|
41
106
|
// of this โ which is what makes "enabled for collections only, for now" a fact about the
|
|
42
107
|
// bundle rather than a feature flag somebody has to remember to check.
|
|
43
108
|
const LazyVideoCutEditor = React.lazy(() => import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })));
|
|
109
|
+
// Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
|
|
110
|
+
// nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
|
|
111
|
+
// this dialog โ the capability and the bundle cost turn on together, with no flag between them.
|
|
112
|
+
const LazyVideoChapterEditor = React.lazy(() => import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })));
|
|
113
|
+
// Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
|
|
114
|
+
// with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
|
|
115
|
+
// dialog โ and the gallery keeps reading whatever tags and descriptions its items already
|
|
116
|
+
// carry either way, because reading was never the capability that was missing.
|
|
117
|
+
const LazyMediaMetaEditor = React.lazy(() => import('./MediaMetaEditor').then((m) => ({ default: m.MediaMetaEditor })));
|
|
118
|
+
/**
|
|
119
|
+
* One glyph per end-behaviour โ the five values the cycling pill used to spell out.
|
|
120
|
+
*
|
|
121
|
+
* ๐ด Five DISTINCT icons, not one icon plus a lit state. The button cycles through settings
|
|
122
|
+
* rather than toggling one, so `Stop at the end` and `Shuffle` have to be told apart at a
|
|
123
|
+
* glance or the only way to read the current value is to hover โ and the owner's own words
|
|
124
|
+
* for this control ("stop at the end") are a value he was reading off the strip, not a name
|
|
125
|
+
* for the button. `aria-label` still spells the value out in full; this is the sighted half
|
|
126
|
+
* of the same fact.
|
|
127
|
+
*
|
|
128
|
+
* `stop` is `ArrowRightToLine` because it is exactly the `โ|` the pill drew before, and it
|
|
129
|
+
* is the one setting that renders UNLIT (`is-active` is withheld for `stop`) โ so "nothing
|
|
130
|
+
* happens at the end" reads as an off state, which is what it is.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately NOT in `playbackPreferences.ts` beside the labels: that module is pure data
|
|
133
|
+
* and storage, imported by apps that drive their own player, and adding a React component to
|
|
134
|
+
* it would pull `lucide-react` into every one of them for a table they never render.
|
|
135
|
+
*/
|
|
136
|
+
const PLAYBACK_END_ICONS = {
|
|
137
|
+
stop: ArrowRightToLine,
|
|
138
|
+
repeat: Repeat1,
|
|
139
|
+
next: SkipForward,
|
|
140
|
+
all: Repeat,
|
|
141
|
+
shuffle: Shuffle,
|
|
142
|
+
};
|
|
44
143
|
/**
|
|
45
144
|
* A stored orientation, as a CSS transform โ the same `previewTransform` the editor previews
|
|
46
145
|
* with, so a saved turn and an unsaved one can never render differently.
|
|
@@ -59,12 +158,42 @@ function orientationStyle(item) {
|
|
|
59
158
|
function isTurned(item) {
|
|
60
159
|
return item.orientation ? swapsAxes(item.orientation.turns) : false;
|
|
61
160
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
161
|
+
/**
|
|
162
|
+
* The one meta line under a tile's picture โ its tags, its measurement, or both.
|
|
163
|
+
*
|
|
164
|
+
* โโ ๐ด What `measure` turns on, and why it is not the default โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
165
|
+
* Owner, `ideas/collections-09-14.md`: *"in the grid view show the video length instead of
|
|
166
|
+
* the size"*, and `ideas/binary-server-09-15.md`: *"show the video length on thumbnail
|
|
167
|
+
* components โฆ show the file size on thumbnail components (maybe controlled through param on
|
|
168
|
+
* media gallery in cb)"*. He guessed the right home for it: ONE param on this gallery, so an
|
|
169
|
+
* app that asks for nothing renders the line it already had, byte for byte.
|
|
170
|
+
*
|
|
171
|
+
* With it on, the measurement a tile leads with is the one that means something for its kind
|
|
172
|
+
* โ a film and a track show their RUNNING TIME, everything else its size. A clip whose
|
|
173
|
+
* duration nobody measured falls back to its size rather than to a blank caption, and a file
|
|
174
|
+
* with neither shows `โ`, the same mark the table's columns use for an absent measurement.
|
|
175
|
+
*
|
|
176
|
+
* ๐ด Tags and the measurement share this line rather than taking one each. The caption block
|
|
177
|
+
* is a fixed 65px โ title, this line, the date โ pinned to `CBGD_META_HEIGHT` because a
|
|
178
|
+
* caption whose height varied per tile is what made rows lap one another across the fleet
|
|
179
|
+
* (see `denseGallery.css`). A fourth line is not available at any price, so a tagged video
|
|
180
|
+
* reads `holiday ยท beach ยท 3:07`.
|
|
181
|
+
*
|
|
182
|
+
* Exported for its own spec: the branch table here is wider than the tile is, and a rendered
|
|
183
|
+
* assertion can only reach the combinations a fixture happens to contain.
|
|
184
|
+
*/
|
|
185
|
+
export function galleryTileLine(item, options = {}) {
|
|
186
|
+
const tags = item.tags && item.tags.length > 0 ? item.tags.slice(0, 3).join(' ยท ') : null;
|
|
187
|
+
const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
|
|
188
|
+
if (!options.measure)
|
|
189
|
+
return tags ?? size;
|
|
190
|
+
const category = galleryItemCategory(item);
|
|
191
|
+
const timeBased = category === 'video' || category === 'audio';
|
|
192
|
+
const measure = timeBased ? (durationLabel(item.durationSeconds) ?? size ?? 'โ') : size;
|
|
193
|
+
return tags && measure ? `${tags} ยท ${measure}` : (tags ?? measure);
|
|
194
|
+
}
|
|
195
|
+
function GalleryMeta({ item, measure }) {
|
|
196
|
+
const line = galleryTileLine(item, { measure: measure === true });
|
|
68
197
|
// The date is its OWN line rather than appended to the one above, because that line is
|
|
69
198
|
// already whichever of tags/size exists โ folding a third fact into it would push the tags
|
|
70
199
|
// out of a tile caption that is two lines tall by design.
|
|
@@ -86,16 +215,16 @@ function extLabel(item) {
|
|
|
86
215
|
* the tile's own click meaning "open" โ ticking the box must never navigate, which is what
|
|
87
216
|
* `stopPropagation` in {@link SelectBox} buys.
|
|
88
217
|
*/
|
|
89
|
-
function TileShell({ name, selection, drag, children, }) {
|
|
218
|
+
function TileShell({ name, selection, drag, stageMark, children, }) {
|
|
90
219
|
const selected = selection?.selected === true;
|
|
91
220
|
const quiet = selection !== undefined && !selection.armed;
|
|
92
|
-
return (_jsxs("div", { className: `cbmg-selectable${selected ? ' is-selected' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`, ...(drag
|
|
221
|
+
return (_jsxs("div", { className: `cbmg-selectable${selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`, "data-playing": stageMark ? 'true' : undefined, "aria-current": stageMark ? 'true' : undefined, ...(drag
|
|
93
222
|
? {
|
|
94
223
|
draggable: true,
|
|
95
224
|
onDragStart: (event) => drag.onDragStart(event),
|
|
96
225
|
onDragEnd: () => drag.onDragEnd?.(),
|
|
97
226
|
}
|
|
98
|
-
: {}), children: [children, selection ? (_jsx(SelectBox, { name: name, selected: selected, onToggle: selection.onToggle })) : null] }));
|
|
227
|
+
: {}), children: [children, stageMark ? (_jsxs("span", { className: 'cbmg-playing-chip', children: [_jsx("span", { "aria-hidden": 'true', children: "\u25B6" }), " ", stageMark] })) : null, selection ? (_jsx(SelectBox, { name: name, selected: selected, onToggle: selection.onToggle })) : null] }));
|
|
99
228
|
}
|
|
100
229
|
function SelectBox({ name, selected, onToggle, }) {
|
|
101
230
|
return (_jsx("span", { className: 'cbmg-select-box', children: _jsx("input", {
|
|
@@ -112,7 +241,7 @@ function SelectBox({ name, selected, onToggle, }) {
|
|
|
112
241
|
}, "aria-label": `Select ${name} for bulk actions (shift-click for a range)` }) }));
|
|
113
242
|
}
|
|
114
243
|
/** One tile: hover-scrub for videos, a still image for photos, a type-icon card otherwise. */
|
|
115
|
-
function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, }) {
|
|
244
|
+
function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, measure, stageMark, }) {
|
|
116
245
|
/*
|
|
117
246
|
* ๐ด ALWAYS wrapped, even with no selection and no drag.
|
|
118
247
|
*
|
|
@@ -127,7 +256,7 @@ function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, }) {
|
|
|
127
256
|
* With both props absent the shell renders exactly one plain div and no interaction
|
|
128
257
|
* handlers, so this costs nothing and cannot change behavior anywhere else.
|
|
129
258
|
*/
|
|
130
|
-
return (_jsx(TileShell, { name: item.name, selection: selection, drag: drag, children: _jsx(RawGalleryTile, { item: item, cache: cache, onOpen: onOpen, autoPreview: autoPreview }) }));
|
|
259
|
+
return (_jsx(TileShell, { name: item.name, selection: selection, drag: drag, stageMark: stageMark, children: _jsx(RawGalleryTile, { item: item, cache: cache, onOpen: onOpen, autoPreview: autoPreview, measure: measure }) }));
|
|
131
260
|
}
|
|
132
261
|
/**
|
|
133
262
|
* A DOCUMENT's tile picture โ page one where a renderer produced it, its FORMAT where not.
|
|
@@ -156,7 +285,7 @@ function DocThumb({ src, alt, category, ext, }) {
|
|
|
156
285
|
}
|
|
157
286
|
return (_jsxs(_Fragment, { children: [_jsx("img", { src: src, alt: alt, loading: 'lazy', onError: () => setFailed(true) }, src), _jsx("span", { className: 'cbmg-doc-badge', children: _jsx(FileTypeIcon, { category: category }) })] }));
|
|
158
287
|
}
|
|
159
|
-
function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
|
|
288
|
+
function RawGalleryTile({ item, cache, onOpen, autoPreview, measure, }) {
|
|
160
289
|
const category = galleryItemCategory(item);
|
|
161
290
|
const { media } = useItemMedia(item, cache);
|
|
162
291
|
if (category === 'video') {
|
|
@@ -171,7 +300,7 @@ function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
|
|
|
171
300
|
return (_jsx(HoverScrubVideoThumb, { video: video, onOpen: onOpen, previewMode: 'scrub',
|
|
172
301
|
// The whole grid previewing at once, on request. The tile suppresses it on
|
|
173
302
|
// coarse-pointer devices by itself, so a phone never plays ninety clips under a thumb.
|
|
174
|
-
autoPlay: autoPreview ?? false, ...(item.orientation ? { orientation: item.orientation } : {}), children: _jsx(GalleryMeta, { item: item }) }));
|
|
303
|
+
autoPlay: autoPreview ?? false, ...(item.orientation ? { orientation: item.orientation } : {}), children: _jsx(GalleryMeta, { item: item, measure: measure }) }));
|
|
175
304
|
}
|
|
176
305
|
const full = category === 'image' ? media.url : undefined;
|
|
177
306
|
const thumb = media.thumbnailUrl ?? full;
|
|
@@ -182,7 +311,7 @@ function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
|
|
|
182
311
|
// A stored orientation turns the TILE too. Without this the grid shows a wall of
|
|
183
312
|
// sideways photographs whose detail view is upright, which reads as the fix not
|
|
184
313
|
// having worked.
|
|
185
|
-
style: orientationStyle(item) }, thumb)) : null })) : (_jsx("div", { className: 'cbmg-doc-media', children: _jsx(DocThumb, { ...(thumb ? { src: thumb } : {}), alt: item.name, category: category, ext: extLabel(item) }) })), _jsx(GalleryMeta, { item: item })] }));
|
|
314
|
+
style: orientationStyle(item) }, thumb)) : null })) : (_jsx("div", { className: 'cbmg-doc-media', children: _jsx(DocThumb, { ...(thumb ? { src: thumb } : {}), alt: item.name, category: category, ext: extLabel(item) }) })), _jsx(GalleryMeta, { item: item, measure: measure })] }));
|
|
186
315
|
}
|
|
187
316
|
/** Bytes for the byte-fed viewers (pdf/sheet/text/code/html), fetched once per item. */
|
|
188
317
|
function useItemBytes(item, url, loadBytes, cache) {
|
|
@@ -287,8 +416,51 @@ export function stagePlayableVideo(item, media, streamUrl) {
|
|
|
287
416
|
// broken one.
|
|
288
417
|
...(media.vttUrl ? { vttUrl: media.vttUrl } : {}),
|
|
289
418
|
...(media.processingStatus ? { processingStatus: media.processingStatus } : {}),
|
|
419
|
+
// The named sections, straight off the ITEM rather than out of `media`: chapters are a
|
|
420
|
+
// property of the film, not of a resolved URL, and they are the same list whichever
|
|
421
|
+
// transport the stage ends up playing. Absent when there are none, so a video with no
|
|
422
|
+
// sections hands the player nothing to draw a chapters menu for.
|
|
423
|
+
...(item.chapters && item.chapters.length > 0 ? { chapters: item.chapters } : {}),
|
|
290
424
|
};
|
|
291
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* ๐ด The stage's session volume, on the NATIVE transport.
|
|
428
|
+
*
|
|
429
|
+
* The Vidstack stage gets this from `VideoPlayer` (`volumeKey`, read off the player's own
|
|
430
|
+
* `volume-change`); this is the same rule for the plain `<video>` an app opts into with
|
|
431
|
+
* `nativeVideoStage`. Same {@link STAGE_VOLUME_KEY}, because it is the same surface โ the two
|
|
432
|
+
* transports are never mounted at once, and keying them apart would mean an app that switched
|
|
433
|
+
* transports lost the level mid-session for no reason a person could name.
|
|
434
|
+
*
|
|
435
|
+
* Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
|
|
436
|
+
* changing it during a session except if I refresh or first log in then continue starting it
|
|
437
|
+
* at mute. Then if I unmute it, keep that value as I change to other videos."*
|
|
438
|
+
*
|
|
439
|
+
* ๐ด The `else` branch is not a no-op: this element carries `autoPlay` and no `muted`, so the
|
|
440
|
+
* fresh-session case starts it silent โ which is both what he asked for and the only state a
|
|
441
|
+
* browser will actually auto-play. The native control bar is right there to unmute.
|
|
442
|
+
*/
|
|
443
|
+
function useStageVolume(ref) {
|
|
444
|
+
React.useEffect(() => {
|
|
445
|
+
const el = ref.current;
|
|
446
|
+
if (!el)
|
|
447
|
+
return;
|
|
448
|
+
const stored = readSessionVolume(STAGE_VOLUME_KEY);
|
|
449
|
+
el.volume = stored?.volume ?? 1;
|
|
450
|
+
el.muted = stored?.muted ?? true;
|
|
451
|
+
// What we just applied โ the element fires `volumechange` for it, and writing that back
|
|
452
|
+
// would mint a preference nobody set. Only what he does after this is his.
|
|
453
|
+
let applied = { volume: el.volume, muted: el.muted };
|
|
454
|
+
const onChange = () => {
|
|
455
|
+
if (el.volume === applied.volume && el.muted === applied.muted)
|
|
456
|
+
return;
|
|
457
|
+
applied = { volume: el.volume, muted: el.muted };
|
|
458
|
+
writeSessionVolume(STAGE_VOLUME_KEY, applied);
|
|
459
|
+
};
|
|
460
|
+
el.addEventListener('volumechange', onChange);
|
|
461
|
+
return () => el.removeEventListener('volumechange', onChange);
|
|
462
|
+
}, [ref]);
|
|
463
|
+
}
|
|
292
464
|
/**
|
|
293
465
|
* The `nativeVideoStage` element โ a plain `<video>` with the ladder attached to it.
|
|
294
466
|
*
|
|
@@ -304,6 +476,7 @@ export function stagePlayableVideo(item, media, streamUrl) {
|
|
|
304
476
|
function NativeVideoStage({ hlsUrl, streamUrl, poster, repeat, onEnded, }) {
|
|
305
477
|
const ref = React.useRef(null);
|
|
306
478
|
useHlsSource(ref, { hlsUrl, streamUrl });
|
|
479
|
+
useStageVolume(ref);
|
|
307
480
|
return (_jsx("video", { ref: ref, className: 'cbmg-stage-video', poster: poster, controls: true, autoPlay: true, playsInline: true, loop: repeat,
|
|
308
481
|
// A looping element never fires `ended`, so passing both would silently disable
|
|
309
482
|
// auto-advance rather than combining the two. See `VideoPlayerProps.loop`.
|
|
@@ -341,7 +514,11 @@ function GalleryStage({ item, mediaCache, bytesCache, loadBytes, nativeVideoStag
|
|
|
341
514
|
if (nativeVideoStage) {
|
|
342
515
|
return frame(_jsx(NativeVideoStage, { hlsUrl: media.hlsUrl, streamUrl: src, poster: media.posterUrl, repeat: repeat, ...(onEnded ? { onEnded } : {}) }, media.hlsUrl ?? src));
|
|
343
516
|
}
|
|
344
|
-
return frame(_jsx(React.Suspense, { fallback: _jsx(StageLoading, { label: 'Loading player\u2026' }), children: _jsx(LazyVideoPlayer, { video: stagePlayableVideo(item, media, src), autoPlay: true,
|
|
517
|
+
return frame(_jsx(React.Suspense, { fallback: _jsx(StageLoading, { label: 'Loading player\u2026' }), children: _jsx(LazyVideoPlayer, { video: stagePlayableVideo(item, media, src), autoPlay: true,
|
|
518
|
+
// ๐ด The STAGE's own volume memory, not the shared primitive's default key โ see
|
|
519
|
+
// `playbackPreferences.ts`. It is what makes the level he set on this clip apply to
|
|
520
|
+
// the next one, and it is per session, so a refresh is silent again.
|
|
521
|
+
volumeKey: STAGE_VOLUME_KEY, loop: repeat, ...(repeat ? {} : { onEnded }), ...(onStep ? { onStep, canStep: canStep !== false } : {}), ...(fullscreenOnDoubleClick === false ? { fullscreenOnDoubleClick: false } : {}) }) }));
|
|
345
522
|
}
|
|
346
523
|
if (category === 'image') {
|
|
347
524
|
if (!media.url)
|
|
@@ -381,7 +558,7 @@ function KindChips({ options, value, onChange, tags, activeTag, onTagChange, })
|
|
|
381
558
|
* The fleet's shared media gallery. Feed it `MediaGalleryItem`s (any binary kind); it
|
|
382
559
|
* renders the dense browse grid and the in-page detail view with the right viewer.
|
|
383
560
|
*/
|
|
384
|
-
export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initialOrder = 'recent', detailActions, onDelete, headerExtra, chrome = 'header', previewAll, onPreviewAllChange, onPreviewableChange, viewMode: controlledViewMode, onViewModeChange, query: controlledQuery, onQueryChange, onSelectedChange, selectedId: controlledSelectedId, loadBytes, empty, emptyAction, minColumnWidth, nativeVideoStage, searchPlaceholder = 'Search filesโฆ', selectable = false, selectionReveal = 'always', onBulkDelete, bulkActions, onSelectionChange, onItemDragStart, onItemDragEnd, onRename, onSaveThumbnail, onSaveSnapshot, onSaveEdit, onSaveOrientation, allowPictureReplace = false, pictureControls = true, onTrimVideo, trimNote, }) {
|
|
561
|
+
export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initialOrder = 'recent', detailActions, onDelete, headerExtra, chrome = 'header', previewAll, onPreviewAllChange, onPreviewableChange, viewMode: controlledViewMode, onViewModeChange, query: controlledQuery, onQueryChange, onSelectedChange, selectedId: controlledSelectedId, loadBytes, empty, emptyAction, minColumnWidth, nativeVideoStage, searchPlaceholder = 'Search filesโฆ', selectable = false, selectionReveal = 'always', onBulkDelete, bulkActions, onSelectionChange, onItemDragStart, onItemDragEnd, onRename, onSaveThumbnail, onSaveSnapshot, onSaveEdit, onSaveOrientation, allowPictureReplace = false, pictureControls = true, onTrimVideo, onSaveChapters, onSaveMeta, trimNote, meta, }) {
|
|
385
562
|
const [uncontrolledQuery, setUncontrolledQuery] = useState('');
|
|
386
563
|
/** Controlled when the consumer passes `query`, uncontrolled otherwise โ see the prop. */
|
|
387
564
|
const query = controlledQuery !== undefined ? controlledQuery : uncontrolledQuery;
|
|
@@ -579,6 +756,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
579
756
|
const [turningVideo, setTurningVideo] = useState(null);
|
|
580
757
|
/** The clip whose cut marks are being made, or null. See `onTrimVideo`. */
|
|
581
758
|
const [trimmingVideo, setTrimmingVideo] = useState(null);
|
|
759
|
+
/** The clip whose sections are being named, or null. See `onSaveChapters`. */
|
|
760
|
+
const [chapteringVideo, setChapteringVideo] = useState(null);
|
|
761
|
+
/**
|
|
762
|
+
* What the tag/description editor is open over, or null. See `onSaveMeta`.
|
|
763
|
+
*
|
|
764
|
+
* ๐ด A LIST rather than one item, because this is the one capability dialog reachable from
|
|
765
|
+
* BOTH screens: the open file's strip passes `[selected]`, and the bulk bar passes the whole
|
|
766
|
+
* ticked set. One piece of state for both is what keeps the two paths from becoming two
|
|
767
|
+
* dialogs that drift โ and it is why `items` is the editor's prop rather than `item`.
|
|
768
|
+
*/
|
|
769
|
+
const [taggingItems, setTaggingItems] = useState(null);
|
|
582
770
|
const [grabbing, setGrabbing] = useState(null);
|
|
583
771
|
const [showPicture, setShowPicture] = useState(false);
|
|
584
772
|
const [light, setLight] = useState(NO_LIGHT);
|
|
@@ -589,8 +777,26 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
589
777
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explained immediately above
|
|
590
778
|
useEffect(() => {
|
|
591
779
|
setRenaming(null);
|
|
592
|
-
setEditingPicture(null);
|
|
593
780
|
setLight(NO_LIGHT);
|
|
781
|
+
/*
|
|
782
|
+
* ๐ด EVERY capability dialog, not just the picture editor โ the owner, 2026-09-14, on the
|
|
783
|
+
* thumbnail chooser in `apps/collections`: *"Clicking on other folders also did not move
|
|
784
|
+
* to view them and the thumbnail selection screen for the first video stayed on the
|
|
785
|
+
* screen. Selecting other sidebar files and icons should also leave that screen."*
|
|
786
|
+
*
|
|
787
|
+
* Only `editingPicture` was cleared here, so a dialog opened against the file you WERE
|
|
788
|
+
* looking at outlived the selection and then described the wrong file: its title, its
|
|
789
|
+
* `src`, its `startAt` and โ the part that loses work โ the item every save handler above
|
|
790
|
+
* is closed over. The fix is one line per dialog rather than a rule in a doc comment,
|
|
791
|
+
* because "remember to clear yours" is exactly the instruction the four dialogs added
|
|
792
|
+
* after this one would not have read.
|
|
793
|
+
*/
|
|
794
|
+
setEditingPicture(null);
|
|
795
|
+
setTurningVideo(null);
|
|
796
|
+
setTrimmingVideo(null);
|
|
797
|
+
setChapteringVideo(null);
|
|
798
|
+
setTaggingItems(null);
|
|
799
|
+
setGrabbing(null);
|
|
594
800
|
}, [selectedId]);
|
|
595
801
|
// ๐ด Enabled whenever there is somewhere else to go, NOT only away from the edges โ the
|
|
596
802
|
// arrows wrap now. A one-item album is the only set with no other item, so it is the only
|
|
@@ -728,6 +934,76 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
728
934
|
const reduced = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
|
|
729
935
|
el.scrollIntoView({ behavior: reduced || snapsToStage(selected) ? 'auto' : 'smooth', block: 'start' });
|
|
730
936
|
}, [selectedId, selected]);
|
|
937
|
+
/**
|
|
938
|
+
* โโ Bring the tile the stage is showing back into view โ the strip's REVEAL โโโโโโโโโโโโโ
|
|
939
|
+
*
|
|
940
|
+
* Owner, `ideas/collections-09-14.md`: *"I want to โฆ still see the playing video in the
|
|
941
|
+
* list."* Keeping it IN the list (the strip above) is only half of that: on a folder of
|
|
942
|
+
* ninety files the marked tile is forty rows away, and a mark nobody can find is a mark
|
|
943
|
+
* nobody has. This is `file-tree/FileTree.tsx`'s reveal, applied to the same problem, and
|
|
944
|
+
* it keeps that one's limits:
|
|
945
|
+
*
|
|
946
|
+
* 1. **Only when the tile is off screen.** A strip that jumps a few pixels every time you
|
|
947
|
+
* click a tile you can already see reads as a bug.
|
|
948
|
+
* 2. **Never mid-drag** โ vacuous here rather than skipped: the strip's tiles are handed no
|
|
949
|
+
* `drag` prop at all (see `renderTile` below), so there is no drag of ours to be in the
|
|
950
|
+
* middle of. `MediaGallery.spec.tsx` asserts that, so a future strip that DOES become a
|
|
951
|
+
* drag source fails a test pointing at this note instead of silently yanking the list
|
|
952
|
+
* out from under a pointer.
|
|
953
|
+
*
|
|
954
|
+
* ๐ด โฆand one limit the tree has no need of: it runs only while the stage is PINNED.
|
|
955
|
+
*
|
|
956
|
+
* The watch view is ONE scroller โ stage on top, strip underneath โ so scrolling the strip
|
|
957
|
+
* scrolls the stage away. The owner has already ruled on that collision, from the other
|
|
958
|
+
* side: *"when I scroll down through thumbnails and click one to play it then I want to
|
|
959
|
+
* navigate automatically back to the top to see the movie I am playing"* (2026-08-14, the
|
|
960
|
+
* effect directly above). Revealing the tile in that mode would undo, every single time,
|
|
961
|
+
* the thing that effect exists to do โ the film would be playing off the top of the screen
|
|
962
|
+
* again, which is the older report re-opened.
|
|
963
|
+
*
|
|
964
|
+
* Pinning is his own answer to it: *"a button to lock the video in place on top so I can
|
|
965
|
+
* scroll thumbnails and still see the full video."* With the top region sticky the stage
|
|
966
|
+
* cannot scroll away, the effect above finds it already in place and moves nothing, and the
|
|
967
|
+
* strip's scroll is free โ so that is exactly the mode where this reveal is not taking
|
|
968
|
+
* something away to give something else.
|
|
969
|
+
*
|
|
970
|
+
* ๐ด It scrolls by a computed delta rather than by `scrollIntoView`, and the sticky region
|
|
971
|
+
* is why: `block: 'nearest'` on a tile ABOVE the fold aligns it to the top of the scroller,
|
|
972
|
+
* which is the seventy percent of it the pinned stage is sitting on โ a "reveal" that puts
|
|
973
|
+
* the tile behind the video. The visible band starts where the pinned region ends.
|
|
974
|
+
*/
|
|
975
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the TRIGGER and is deliberately not read in the body โ the tile is found through the DOM by the mark that identifies it. `pinStage` is a GUARD and not a trigger: pressing the pin would otherwise scroll the strip on its own, which is movement nobody asked for.
|
|
976
|
+
useEffect(() => {
|
|
977
|
+
const stage = stageRef.current;
|
|
978
|
+
if (!selectedId || !pinStage || !stage)
|
|
979
|
+
return;
|
|
980
|
+
const scroller = stage.closest('.cbgd-watch');
|
|
981
|
+
// The strip's own marked element, in whichever view is on: a tile shell in the grid, a
|
|
982
|
+
// row in the table. Both carry `data-playing` for exactly this.
|
|
983
|
+
const mark = scroller?.querySelector('[data-playing]');
|
|
984
|
+
if (!scroller || !mark)
|
|
985
|
+
return;
|
|
986
|
+
// The table scrolls inside its own frame (`.cbgt-root`), where the stage is not even a
|
|
987
|
+
// sibling; the tile grid windows against the watch scroller itself.
|
|
988
|
+
const frame = mark.closest('.cbgt-root') ?? scroller;
|
|
989
|
+
const view = frame.getBoundingClientRect();
|
|
990
|
+
// Where the pinned region stops covering the strip. Absent (or not pinned) โ the frame's
|
|
991
|
+
// own top edge, which is what the table's separate scroller always uses.
|
|
992
|
+
const pinned = frame === scroller ? scroller.querySelector('.cbgd-watch-top') : null;
|
|
993
|
+
const top = pinned ? Math.max(view.top, pinned.getBoundingClientRect().bottom) : view.top;
|
|
994
|
+
const box = mark.getBoundingClientRect();
|
|
995
|
+
const delta = box.top < top ? box.top - top : box.bottom > view.bottom ? box.bottom - view.bottom : 0;
|
|
996
|
+
// Limit 1: already in the band, so nothing moves.
|
|
997
|
+
if (delta === 0)
|
|
998
|
+
return;
|
|
999
|
+
frame.scrollTop += delta;
|
|
1000
|
+
}, [selectedId]);
|
|
1001
|
+
/**
|
|
1002
|
+
* Is one of the capability dialogs on screen? Every one of them is a focus-trapped modal
|
|
1003
|
+
* over the gallery, so for as long as one is up the window-level shortcuts below are not
|
|
1004
|
+
* the gallery's to take โ see the guard in the effect that follows.
|
|
1005
|
+
*/
|
|
1006
|
+
const capabilityDialogOpen = Boolean(editingPicture || turningVideo || trimmingVideo || chapteringVideo || grabbing);
|
|
731
1007
|
/**
|
|
732
1008
|
* Arrow keys step, Escape closes.
|
|
733
1009
|
*
|
|
@@ -735,9 +1011,22 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
735
1011
|
* the thing the owner just clicked and it owns focus โ a handler on an ancestor div only
|
|
736
1012
|
* fires when focus happens to be outside the player, which is the state a person watching a
|
|
737
1013
|
* video is never in. The typing guard below is what makes a window binding safe.
|
|
1014
|
+
*
|
|
1015
|
+
* โโ ๐ด โฆand it stands DOWN while a capability dialog is open โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1016
|
+
* The owner, 2026-09-14, on the thumbnail chooser: *"the space bar should toggle playing
|
|
1017
|
+
* the video no matter what is focused but isn't working on the thumbnail selection page."*
|
|
1018
|
+
*
|
|
1019
|
+
* It was firing โ on the wrong film. This handler binds at CAPTURE on `window`, so a press
|
|
1020
|
+
* inside an open dialog reached it first and toggled the STAGE player behind the dialog,
|
|
1021
|
+
* which is a video the reader cannot see. `FrameGrabber` now binds the same fleet rule
|
|
1022
|
+
* (`usePlayPauseSpace`) to its own `<video>`, and two capture listeners on one window both
|
|
1023
|
+
* "working" is how a single press pauses two different films โ `stopPropagation` does not
|
|
1024
|
+
* help, because it does not stop other listeners on the same target. So the gallery yields
|
|
1025
|
+
* the key instead: whoever is on top owns it. The same guard is what stops Escape closing
|
|
1026
|
+
* the gallery's selection out from under a dialog that Radix was about to close on its own.
|
|
738
1027
|
*/
|
|
739
1028
|
useEffect(() => {
|
|
740
|
-
if (!selectedId)
|
|
1029
|
+
if (!selectedId || capabilityDialogOpen)
|
|
741
1030
|
return;
|
|
742
1031
|
const onKey = (event) => {
|
|
743
1032
|
/*
|
|
@@ -875,10 +1164,18 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
875
1164
|
*/
|
|
876
1165
|
const capabilityDialogs = (_jsxs(React.Suspense, { fallback: null, children: [editingPicture ? (_jsx(LazyPictureEditor, { src: editingPicture.url ?? '', fileName: editingPicture.name, allowReplace: allowPictureReplace, onClose: () => setEditingPicture(null), onSave: async (result) => {
|
|
877
1166
|
await onSaveEdit?.(editingPicture, result);
|
|
878
|
-
} })) : null, turningVideo ? (_jsx(
|
|
1167
|
+
} })) : null, turningVideo ? (_jsx(TurnVideoDialog, { item: turningVideo, cache: mediaCache, onClose: () => setTurningVideo(null), onSave: async (next) => {
|
|
879
1168
|
await onSaveOrientation?.(turningVideo, next);
|
|
880
1169
|
} })) : null, trimmingVideo && onTrimVideo ? (_jsx(LazyVideoCutEditor, { src: trimmingVideo.streamUrl ?? trimmingVideo.url ?? '', ...(trimmingVideo.posterUrl ? { posterUrl: trimmingVideo.posterUrl } : {}), name: trimmingVideo.name, durationSeconds: trimmingVideo.durationSeconds ?? null, ...(trimNote ? { note: trimNote } : {}), onClose: () => setTrimmingVideo(null), onSubmit: async (cuts) => {
|
|
881
1170
|
await onTrimVideo(trimmingVideo, cuts);
|
|
1171
|
+
} })) : null, chapteringVideo && onSaveChapters ? (_jsx(LazyVideoChapterEditor, { src: chapteringVideo.streamUrl ?? chapteringVideo.url ?? '', ...(chapteringVideo.posterUrl ? { posterUrl: chapteringVideo.posterUrl } : {}), name: chapteringVideo.name, durationSeconds: chapteringVideo.durationSeconds ?? null, ...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {}), onClose: () => setChapteringVideo(null), onSubmit: async (chapters) => {
|
|
1172
|
+
await onSaveChapters(chapteringVideo, chapters);
|
|
1173
|
+
} })) : null, taggingItems && taggingItems.length > 0 && onSaveMeta ? (_jsx(LazyMediaMetaEditor, { items: taggingItems,
|
|
1174
|
+
// The album's own vocabulary, which is already computed for the filter chips โ
|
|
1175
|
+
// offering it is what stops `holiday`, `Holiday` and `holidays` being minted as
|
|
1176
|
+
// three chips that each find a third of the set.
|
|
1177
|
+
suggestions: tags, onClose: () => setTaggingItems(null), onSubmit: async (change) => {
|
|
1178
|
+
await onSaveMeta(taggingItems, change);
|
|
882
1179
|
} })) : null, grabbing ? (_jsx(LazyFrameGrabber, { src: grabbing.item.streamUrl ?? grabbing.item.url ?? '', name: grabbing.item.name, startAt: grabbing.at,
|
|
883
1180
|
// So the dialog can SAY that saving replaces the picture this film shows now, rather
|
|
884
1181
|
// than overwriting somebody's chosen poster silently โ the owner's copy-not-overwrite
|
|
@@ -892,8 +1189,36 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
892
1189
|
: {}) })) : null] }));
|
|
893
1190
|
if (selected) {
|
|
894
1191
|
const category = galleryItemCategory(selected);
|
|
895
|
-
|
|
1192
|
+
/*
|
|
1193
|
+
* โโ ๐ด The strip under the stage is EVERY displayed item, the open one included โโโโโโโ
|
|
1194
|
+
*
|
|
1195
|
+
* Owner, `ideas/collections-09-14.md`: *"sometimes I click on a video and while it's
|
|
1196
|
+
* playing I click on a different video in the thumbnails under the playing video and the
|
|
1197
|
+
* video I click on gets taken out of the list. I want to change this cb media gallery
|
|
1198
|
+
* behavior so that I can still see the playing video in the list and nothing about the
|
|
1199
|
+
* list changes as I pick other videos."*
|
|
1200
|
+
*
|
|
1201
|
+
* This line was `displayItems.filter((i) => i.id !== selected.id)` โ the strip was
|
|
1202
|
+
* everything EXCEPT what was playing. So every pick removed one item and put the previous
|
|
1203
|
+
* one back, and because the two sit at different places in the sort, every tile between
|
|
1204
|
+
* them shifted by one: the list re-sorted itself under his pointer on every click, which
|
|
1205
|
+
* is what *"the video I click on gets taken out of the list"* is describing. His
|
|
1206
|
+
* requirement is stated twice and is the exact opposite, so there is no filter here at
|
|
1207
|
+
* all now โ the strip is `displayItems`, in `displayItems`' order, and the open one is
|
|
1208
|
+
* MARKED (`stageMark` below) instead of removed.
|
|
1209
|
+
*
|
|
1210
|
+
* ๐ด Nothing downstream ever depended on the exclusion. `step`/โง-arrows and `advance`
|
|
1211
|
+
* have always run over `displayIds` โ the unfiltered displayed order โ so their
|
|
1212
|
+
* arithmetic is untouched by this, and `nextInRun` already refuses to draw the current
|
|
1213
|
+
* item as the next one (`playbackRun.ts`, and `MediaGallery.spec.tsx` asserts it from
|
|
1214
|
+
* this edge). What DID depend on it was the heading, which said *"More files"*; with the
|
|
1215
|
+
* open file in the list that word is no longer true, so it is gone too.
|
|
1216
|
+
*/
|
|
1217
|
+
const stageMark = category === 'video' || category === 'audio' ? 'Now playing' : 'On screen';
|
|
896
1218
|
const downloadHref = selected.downloadUrl ?? selected.url;
|
|
1219
|
+
// Capitalised so JSX reads it as a component rather than an HTML tag โ see
|
|
1220
|
+
// `PLAYBACK_END_ICONS` for why the setting gets five glyphs and not one.
|
|
1221
|
+
const PlaybackEndIcon = PLAYBACK_END_ICONS[playbackEnd];
|
|
897
1222
|
return (_jsxs(_Fragment, { children: [capabilityDialogs, _jsx(DenseGalleryDetail, { fullStage: fullStage, onFullStageChange: setFullStage,
|
|
898
1223
|
/* ๐ด A VIDEO enters browser fullscreen from its own player, so the frame must not also
|
|
899
1224
|
claim the gesture; every other kind of file has no player, so the frame's
|
|
@@ -903,7 +1228,7 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
903
1228
|
// The date joins the line rather than replacing what was there โ beside the title is
|
|
904
1229
|
// exactly where the owner asked for it, and the titlebar has the room a tile caption
|
|
905
1230
|
// does not.
|
|
906
|
-
[
|
|
1231
|
+
stageMeta([
|
|
907
1232
|
selected.tags && selected.tags.length > 0
|
|
908
1233
|
? selected.tags.join(' ยท ')
|
|
909
1234
|
: selected.sizeBytes != null
|
|
@@ -912,7 +1237,7 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
912
1237
|
createdLabel(selected.createdAt),
|
|
913
1238
|
]
|
|
914
1239
|
.filter(Boolean)
|
|
915
|
-
.join(' ยท ') || undefined, sticky: pinStage, stage: _jsx("div", { ref: stageRef, className: 'cbmg-stage-anchor',
|
|
1240
|
+
.join(' ยท ') || undefined, selected.description), sticky: pinStage, stage: _jsx("div", { ref: stageRef, className: 'cbmg-stage-anchor',
|
|
916
1241
|
/*
|
|
917
1242
|
* ๐ด The picture adjustment reaches the stage through a CSS custom property, not
|
|
918
1243
|
* through a prop on the player. The video stage is Vidstack's and is several
|
|
@@ -944,17 +1269,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
944
1269
|
? 'Unpin โ let the stage scroll away with the page'
|
|
945
1270
|
: 'Pin the stage to the top while the thumbnails scroll under it', children: _jsx("button", {
|
|
946
1271
|
// guardrails-ignore no-raw-action-button: see the previous button.
|
|
947
|
-
type: 'button', className:
|
|
1272
|
+
type: 'button', className: `cbgd-order-btn is-icon${pinStage ? ' is-active' : ''}`, "aria-pressed": pinStage, "aria-label": pinStage ? 'Pinned โ unpin the stage' : 'Pin the stage', onClick: () => {
|
|
948
1273
|
const next = !pinStage;
|
|
949
1274
|
setPinStage(next);
|
|
950
1275
|
writePinStage(next);
|
|
951
|
-
}, children:
|
|
1276
|
+
}, children: _jsx(Pin, { "aria-hidden": true }) }) }), _jsx(Tooltip, { content: `${PLAYBACK_END_LABELS[playbackEnd].label} โ ${PLAYBACK_END_LABELS[playbackEnd].hint}`, children: _jsx("button", {
|
|
952
1277
|
// guardrails-ignore no-raw-action-button: see the previous button.
|
|
953
|
-
type: 'button', className: `cbgd-order-btn${playbackEnd === 'stop' ? '' : ' is-active'}`, onClick: () => {
|
|
1278
|
+
type: 'button', className: `cbgd-order-btn is-icon${playbackEnd === 'stop' ? '' : ' is-active'}`, onClick: () => {
|
|
954
1279
|
const next = nextPlaybackEnd(playbackEnd);
|
|
955
1280
|
setPlaybackEnd(next);
|
|
956
1281
|
writePlaybackEnd(next);
|
|
957
|
-
}, "aria-label": `When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`, children:
|
|
1282
|
+
}, "aria-label": `When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`, children: _jsx(PlaybackEndIcon, { "aria-hidden": true }) }) }), runAdvances(playbackEnd) ? (_jsx(Tooltip, { content: 'How long each picture holds the screen before the next item', children: _jsxs("label", { className: 'cbgd-dwell', children: [_jsx("span", { className: 'cbgd-dwell-label', children: _jsx(Timer, { "aria-hidden": true }) }), _jsx("select", {
|
|
958
1283
|
// guardrails-ignore no-raw-inputs: `.cbgd-dwell` is this module's own
|
|
959
1284
|
// self-contained plain-CSS control, for the same reason `.cbgd-search`
|
|
960
1285
|
// states โ cursedbelt's Tailwind field layer would fight it on cascade.
|
|
@@ -987,14 +1312,22 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
987
1312
|
type: 'button', className: 'cbgd-order-btn', onClick: () => setRenaming(null), children: "Cancel" })] })) : (_jsx(Tooltip, { content: "Change this file's name", children: _jsx("button", {
|
|
988
1313
|
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
989
1314
|
// gallery's own plain CSS, matching its neighbors.
|
|
990
|
-
type: 'button', className: 'cbgd-order-btn', onClick: () => {
|
|
1315
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Rename', onClick: () => {
|
|
991
1316
|
setRenameDraft(selected.name);
|
|
992
1317
|
setRenaming(selected.id);
|
|
993
|
-
}, children: "
|
|
1318
|
+
}, children: _jsx(Pencil, { "aria-hidden": true }) }) }))) : null, onSaveMeta ? (_jsx(Tooltip, { content: 'Tag this file and say what it is', children: _jsx("button", {
|
|
1319
|
+
// guardrails-ignore no-raw-action-button: see above.
|
|
1320
|
+
type: 'button', className: `cbgd-order-btn is-icon${(selected.tags && selected.tags.length > 0) || selected.description
|
|
1321
|
+
? ' is-active'
|
|
1322
|
+
: ''}`, "aria-label": 'Tags and description', onClick: () => setTaggingItems([selected]), children: _jsx(Tags, { "aria-hidden": true }) }) })) : null, (onSaveThumbnail || onSaveSnapshot) && category === 'video' ? (_jsx(Tooltip, { content: onSaveThumbnail
|
|
994
1323
|
? 'Pick which moment the thumbnail comes from, and keep stills'
|
|
995
1324
|
: 'Keep a full-size picture of a moment in this film', children: _jsx("button", {
|
|
996
1325
|
// guardrails-ignore no-raw-action-button: see above.
|
|
997
|
-
type: 'button', className: 'cbgd-order-btn',
|
|
1326
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": onSaveThumbnail
|
|
1327
|
+
? selected.posterUrl
|
|
1328
|
+
? 'Change thumbnail'
|
|
1329
|
+
: 'Choose thumbnail'
|
|
1330
|
+
: 'Take a picture', onClick: () => {
|
|
998
1331
|
/*
|
|
999
1332
|
* ๐ด Carry the PLAYHEAD across, read off the DOM rather than plumbed through.
|
|
1000
1333
|
* The stage player is Vidstack's and owns its own time; this is one number at
|
|
@@ -1006,19 +1339,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
1006
1339
|
const stage = stageRef.current?.querySelector('.cbmg-stage-video, video');
|
|
1007
1340
|
const at = stage && Number.isFinite(stage.currentTime) ? stage.currentTime : 0;
|
|
1008
1341
|
setGrabbing({ item: selected, at });
|
|
1009
|
-
}, children: onSaveThumbnail
|
|
1010
|
-
? selected.posterUrl
|
|
1011
|
-
? '๐ผ Change thumbnail'
|
|
1012
|
-
: '๐ผ Choose thumbnail'
|
|
1013
|
-
: '๐ท Take a picture' }) })) : null, onSaveEdit && category === 'image' ? (_jsx(Tooltip, { content: 'Turn, mirror, crop or re-light this picture', children: _jsx("button", {
|
|
1342
|
+
}, children: onSaveThumbnail ? _jsx(ImageIcon, { "aria-hidden": true }) : _jsx(Camera, { "aria-hidden": true }) }) })) : null, onSaveEdit && category === 'image' ? (_jsx(Tooltip, { content: 'Turn, mirror, crop or re-light this picture', children: _jsx("button", {
|
|
1014
1343
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1015
|
-
type: 'button', className: 'cbgd-order-btn', onClick: () => setEditingPicture(selected), children:
|
|
1344
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Edit picture', onClick: () => setEditingPicture(selected), children: _jsx(Crop, { "aria-hidden": true }) }) })) : null, onSaveOrientation && category === 'video' ? (_jsx(Tooltip, { content: 'Fix a clip filmed sideways \u2014 stored, never re-encoded', children: _jsx("button", {
|
|
1016
1345
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1017
|
-
type: 'button', className: 'cbgd-order-btn', onClick: () => setTurningVideo(selected), children:
|
|
1346
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Turn video', onClick: () => setTurningVideo(selected), children: _jsx(RotateCw, { "aria-hidden": true }) }) })) : null, onTrimVideo && category === 'video' ? (_jsx(Tooltip, { content: 'Mark sections to cut out \u2014 a trimmed copy is made beside the original', children: _jsx("button", {
|
|
1018
1347
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1019
|
-
type: 'button', className: 'cbgd-order-btn', onClick: () => setTrimmingVideo(selected), children:
|
|
1348
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Trim', onClick: () => setTrimmingVideo(selected), children: _jsx(Scissors, { "aria-hidden": true }) }) })) : null, onSaveChapters && category === 'video' ? (_jsx(Tooltip, { content: 'Name the parts of this film \u2014 the player shows them on the scrub bar', children: _jsx("button", {
|
|
1020
1349
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1021
|
-
type: 'button', className:
|
|
1350
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Sections', onClick: () => setChapteringVideo(selected), children: _jsx(ListVideo, { "aria-hidden": true }) }) })) : null, pictureControls && (category === 'image' || category === 'video') ? (_jsx(Tooltip, { content: 'Brightness, contrast and colour for what you are looking at', children: _jsx("button", {
|
|
1351
|
+
// guardrails-ignore no-raw-action-button: see above.
|
|
1352
|
+
type: 'button', className: `cbgd-order-btn is-icon${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`, "aria-pressed": showPicture, "aria-label": isLightAdjusted(light) ? 'Picture โ adjusted' : 'Picture', onClick: () => setShowPicture((on) => !on), children: _jsx(Sun, { "aria-hidden": true }) }) })) : null, showPicture ? (_jsxs("span", { className: 'cbmg-picture-sliders', children: [[
|
|
1022
1353
|
['brightness', 'Brightness'],
|
|
1023
1354
|
['contrast', 'Contrast'],
|
|
1024
1355
|
['saturation', 'Colour'],
|
|
@@ -1026,101 +1357,150 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
|
|
|
1026
1357
|
// guardrails-ignore no-raw-inputs: a plain control inside a Tailwind-free, plain-CSS row. The Field layer has no slider, and its label chrome would duplicate the label already beside this one.
|
|
1027
1358
|
type: 'range', min: LIGHT_RANGE.min, max: LIGHT_RANGE.max, step: LIGHT_RANGE.step, value: light[key], onChange: (event) => setLight((l) => ({ ...l, [key]: Number(event.target.value) })) })] }, key))), _jsx("button", {
|
|
1028
1359
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1029
|
-
type: 'button', className: 'cbgd-order-btn', disabled: !isLightAdjusted(light), onClick: () => setLight(NO_LIGHT), children: "Reset" })] })) : null, detailActions?.(selected), downloadHref ? (_jsx(Tooltip, { content: `Download ${selected.name}`, children: _jsx("a", { className: 'cbgd-order-btn', href: downloadHref, download: selected.name, children:
|
|
1360
|
+
type: 'button', className: 'cbgd-order-btn', disabled: !isLightAdjusted(light), onClick: () => setLight(NO_LIGHT), children: "Reset" })] })) : null, detailActions?.(selected), downloadHref ? (_jsx(Tooltip, { content: `Download ${selected.name}`, children: _jsx("a", { className: 'cbgd-order-btn is-icon', href: downloadHref, download: selected.name, "aria-label": `Download ${selected.name}`, children: _jsx(Download, { "aria-hidden": true }) }) })) : null, onDelete ? (_jsx(DeleteButton, { iconOnly: true, "aria-label": 'Delete this item', itemLabel: CATEGORY_LABEL[category].toLowerCase(), tooltip: 'Delete this item', onDelete: async () => {
|
|
1030
1361
|
await onDelete(selected);
|
|
1031
1362
|
select(null);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1363
|
+
} })) : null, _jsx(Tooltip, { content: 'Close this file and go back to the grid', children: _jsx("button", {
|
|
1364
|
+
// guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
|
|
1365
|
+
// the dense gallery's own plain CSS (plugin-media-gallery precedent).
|
|
1366
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Back to grid', onClick: () => select(null), children: _jsx(ArrowLeft, { "aria-hidden": true }) }) })] }),
|
|
1367
|
+
/* Not "More files" โ the strip holds the open file too now, so "more" would be the
|
|
1368
|
+
heading contradicting the list under it. */
|
|
1369
|
+
relatedLabel: 'Files', itemCount: displayItems.length, loading: loading, onNearEnd: onNearEnd, minColumnWidth: minColumnWidth, ...(viewMode === 'table'
|
|
1036
1370
|
? {
|
|
1037
|
-
relatedBody: (_jsx(GalleryTable, { items:
|
|
1371
|
+
relatedBody: (_jsx(GalleryTable, { items: displayItems,
|
|
1372
|
+
// The open file is in this list exactly as it is in the tile strip, so it is
|
|
1373
|
+
// marked here exactly as it is there.
|
|
1374
|
+
playing: { id: selected.id, label: stageMark }, cache: mediaCache, order: order, onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }),
|
|
1038
1375
|
/* ๐ด The same `thumbAutoplay` the tiles take. Owner, 2026-09-11: *"in table
|
|
1039
1376
|
view the play all thumbnails isn't working but if I go back to grid view it
|
|
1040
1377
|
works."* This is the RELATED list under an open file, and it is the same
|
|
1041
1378
|
table as the browse view's โ so leaving it out here would fix the toggle on
|
|
1042
1379
|
one of the two screens it is wrong on. */
|
|
1043
|
-
autoPreview: thumbAutoplay
|
|
1380
|
+
autoPreview: thumbAutoplay,
|
|
1381
|
+
/* Same argument for the exact-bytes column: this IS the browse view's
|
|
1382
|
+
table, so a column it offered and this one withheld would be the
|
|
1383
|
+
feature vanishing the moment you opened a file. */
|
|
1384
|
+
exactBytes: meta?.exactBytes === true })),
|
|
1044
1385
|
}
|
|
1045
1386
|
: {}), renderTile: (index) => {
|
|
1046
|
-
const item =
|
|
1387
|
+
const item = displayItems[index];
|
|
1047
1388
|
return item ? (_jsx(GalleryTile, { item: item, cache: mediaCache, onOpen: () => select(item),
|
|
1048
1389
|
// The toggle means what it says on both screens โ "Preview all" that stopped
|
|
1049
1390
|
// applying the moment you opened something would be a different feature.
|
|
1050
|
-
autoPreview: thumbAutoplay
|
|
1391
|
+
autoPreview: thumbAutoplay,
|
|
1392
|
+
// โฆand so does the running time on a film's caption: the strip under an open
|
|
1393
|
+
// file is the same grid, and a tile that told you its length on one screen and
|
|
1394
|
+
// its size on the other would be two tiles.
|
|
1395
|
+
measure: meta?.tileMeasure === true, ...(item.id === selected.id ? { stageMark } : {}) }, item.id)) : null;
|
|
1051
1396
|
} })] }));
|
|
1052
1397
|
}
|
|
1053
|
-
return (_jsx(DenseGalleryShell, { title: title, order: {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1398
|
+
return (_jsxs(_Fragment, { children: [capabilityDialogs, _jsx(DenseGalleryShell, { title: title, order: {
|
|
1399
|
+
value: order,
|
|
1400
|
+
options: GALLERY_ORDER_OPTIONS,
|
|
1401
|
+
onChange: setOrder,
|
|
1402
|
+
ariaLabel: 'Sort files',
|
|
1403
|
+
}, query: query, onQueryChange: setQuery, searchPlaceholder: searchPlaceholder, chrome: chrome, headerExtra: _jsxs(_Fragment, { children: [_jsx("fieldset", { className: 'cbgd-order', "aria-label": 'How files are shown', children: [
|
|
1404
|
+
{ mode: 'grid', Icon: LayoutGrid, label: 'Grid', hint: 'Show files as thumbnails' },
|
|
1405
|
+
{
|
|
1406
|
+
mode: 'table',
|
|
1407
|
+
Icon: Rows3,
|
|
1408
|
+
label: 'Table',
|
|
1409
|
+
hint: 'Show files as rows with size, length and date',
|
|
1410
|
+
},
|
|
1411
|
+
].map((option) => (_jsx(Tooltip, { content: option.hint, children: _jsx("button", {
|
|
1412
|
+
// guardrails-ignore no-raw-action-button: header pill styled by the dense
|
|
1413
|
+
// gallery's own plain CSS, matching its sort neighbours.
|
|
1414
|
+
type: 'button', className: `cbgd-order-btn is-icon${viewMode === option.mode ? ' is-active' : ''}`, "aria-pressed": viewMode === option.mode, "aria-label": option.label, onClick: () => setViewMode(option.mode), children: _jsx(option.Icon, { "aria-hidden": true }) }) }, option.mode))) }), hasPreviewable ? (_jsx(Tooltip, { content: thumbAutoplay
|
|
1415
|
+
? 'Stop previewing every clip โ hover one to preview it'
|
|
1416
|
+
: 'Preview every clip at once, without hovering', children: _jsx("button", {
|
|
1417
|
+
// guardrails-ignore no-raw-action-button: header pill styled by the dense
|
|
1418
|
+
// gallery's own plain CSS, matching its sort neighbours.
|
|
1419
|
+
type: 'button', className: `cbgd-order-btn is-icon${thumbAutoplay ? ' is-active' : ''}`, "aria-pressed": thumbAutoplay, "aria-label": 'Preview all', onClick: () => setThumbAutoplay(!thumbAutoplay), children: _jsx(Play, { "aria-hidden": true }) }) })) : null, headerExtra] }), filterStrip: _jsx(KindChips, { options: kindOptions, value: kind, onChange: setKind, tags: tags, activeTag: tag, onTagChange: setTag }), bulkBar: selectable ? (_jsx(BulkBar, { count: checks.count, total: displayItems.length, headerState: checks.headerState, onToggleAll: checks.toggleAll, onClear: () => {
|
|
1420
|
+
checks.clear();
|
|
1421
|
+
setSelectArmed(false);
|
|
1422
|
+
}, ...(selectionReveal === 'on-demand'
|
|
1423
|
+
? { armed: selectArmed, onToggleArmed: () => setSelectArmed((on) => !on) }
|
|
1424
|
+
: {}), onDelete: onBulkDelete
|
|
1425
|
+
? async () => {
|
|
1426
|
+
await onBulkDelete(checkedItems);
|
|
1427
|
+
checks.clear();
|
|
1428
|
+
}
|
|
1429
|
+
: undefined,
|
|
1430
|
+
/* ๐ด The SAME handler and the same dialog the open file's strip uses โ it is
|
|
1431
|
+
handed a list either way, so there is no singular path for a plural gesture to
|
|
1432
|
+
drift away from. Tagging forty holiday photographs one at a time is the case
|
|
1433
|
+
the owner will actually hit, and it is one call carrying forty ids. */
|
|
1434
|
+
onTag: onSaveMeta ? () => setTaggingItems(checkedItems) : undefined, extra: bulkActions?.(checkedItems, checks.clear) })) : null, itemCount: displayItems.length, loading: loading, onNearEnd: onNearEnd, empty: empty ?? _jsx("div", { className: 'cbgd-empty', children: "No files yet." }), emptyAction: emptyAction, minColumnWidth: minColumnWidth, body: viewMode === 'table' ? (_jsx(GalleryTable, { items: displayItems, cache: mediaCache, order: order,
|
|
1435
|
+
// ๐ด The table drives the gallery's OWN sort. Two views with two sorts is a
|
|
1436
|
+
// question nobody should have to answer; see GalleryTable's header.
|
|
1437
|
+
onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }), ...(empty === undefined ? {} : { empty }),
|
|
1438
|
+
/* See the related table in the detail view above โ "Preview all" has to mean the
|
|
1439
|
+
same thing in both views or it is two features wearing one label. */
|
|
1440
|
+
autoPreview: thumbAutoplay, exactBytes: meta?.exactBytes === true, ...(selectable
|
|
1441
|
+
? {
|
|
1442
|
+
selection: (item) => ({
|
|
1443
|
+
selected: checks.isSelected(item.id),
|
|
1444
|
+
onToggle: (shiftKey) => checks.toggle(item.id, { shiftKey }),
|
|
1445
|
+
}),
|
|
1446
|
+
}
|
|
1447
|
+
: {}), ...(onItemDragStart ? { drag: tileDrag } : {}) })) : null, renderTile: (index) => {
|
|
1448
|
+
const item = displayItems[index];
|
|
1449
|
+
return item ? (_jsx(GalleryTile, { item: item, cache: mediaCache, onOpen: () => select(item), selection: tileSelection(item), drag: tileDrag(item), autoPreview: thumbAutoplay, measure: meta?.tileMeasure === true }, item.id)) : null;
|
|
1450
|
+
} })] }));
|
|
1101
1451
|
}
|
|
1102
1452
|
const isItem = (i) => i !== undefined;
|
|
1453
|
+
/**
|
|
1454
|
+
* The titlebar's meta slot โ the one-line facts, plus the DESCRIPTION under them.
|
|
1455
|
+
*
|
|
1456
|
+
* ๐ด The description gets its OWN element rather than joining the ` ยท ` line, and that is a
|
|
1457
|
+
* layout fact rather than a preference: `.cbgd-meta-line` is `white-space: nowrap` with an
|
|
1458
|
+
* ellipsis, because the same class captions every tile in the grid. A sentence appended to
|
|
1459
|
+
* that line is a sentence nobody can read past its first few words โ the exact failure the
|
|
1460
|
+
* task warns about for the TILE caption, one element further up.
|
|
1461
|
+
*
|
|
1462
|
+
* So the slot becomes two children of the titlebar's own wrapping flex row: the facts stay on
|
|
1463
|
+
* the nowrap line they were always on, and `.cbmg-stage-description` is a block that wraps and
|
|
1464
|
+
* is width-capped for readability (`mediaGallery.css`). An item with no description renders
|
|
1465
|
+
* byte-for-byte what it rendered before โ `undefined` in, `undefined` out โ which is what
|
|
1466
|
+
* keeps this from restyling every gallery in the fleet.
|
|
1467
|
+
*/
|
|
1468
|
+
function stageMeta(line, description) {
|
|
1469
|
+
const words = description?.trim();
|
|
1470
|
+
if (!words)
|
|
1471
|
+
return line;
|
|
1472
|
+
return (_jsxs(_Fragment, { children: [line ? _jsx("span", { className: 'cbmg-stage-facts', children: line }) : null, _jsx("span", { className: 'cbmg-stage-description', children: words })] }));
|
|
1473
|
+
}
|
|
1103
1474
|
/**
|
|
1104
1475
|
* The multi-select bar. Always mounted while `selectable` (never conditionally, so the grid
|
|
1105
1476
|
* does not jump the moment the first box is ticked) โ the destructive half only appears
|
|
1106
1477
|
* once something is actually selected.
|
|
1107
1478
|
*/
|
|
1108
|
-
function BulkBar({ count, total, headerState, armed, onToggleArmed, onToggleAll, onClear, onDelete, extra, }) {
|
|
1479
|
+
function BulkBar({ count, total, headerState, armed, onToggleArmed, onToggleAll, onClear, onDelete, onTag, extra, }) {
|
|
1109
1480
|
return (_jsxs("div", { className: 'cbmg-bulkbar', children: [onToggleArmed ? (_jsx(Tooltip, { content: armed ? 'Hide the tick boxes again' : 'Show a tick box on every tile', children: _jsx("button", {
|
|
1110
1481
|
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
1111
1482
|
// gallery's own plain CSS, matching its neighbors.
|
|
1112
|
-
type: 'button', className: `cbgd-order-btn${armed ? ' is-active' : ''}`, "aria-pressed": armed === true,
|
|
1483
|
+
type: 'button', className: `cbgd-order-btn is-icon${armed ? ' is-active' : ''}`, "aria-pressed": armed === true, "aria-label": armed ? 'Done selecting' : 'Select', onClick: onToggleArmed, children: _jsx(SquareCheck, { "aria-hidden": true }) }) })) : null, _jsxs("label", { className: 'cbmg-bulkbar-all', children: [_jsx("input", {
|
|
1113
1484
|
// guardrails-ignore no-raw-inputs: a plain control inside a Tailwind-free, plain-CSS row. The Field layer has no slider, and its label chrome would duplicate the label already beside this one.
|
|
1114
1485
|
// guardrails-ignore no-raw-inputs: plain-CSS module โ see TileSelect.
|
|
1115
1486
|
type: 'checkbox', checked: headerState === true, ref: (el) => {
|
|
1116
1487
|
if (el)
|
|
1117
1488
|
el.indeterminate = headerState === 'indeterminate';
|
|
1118
|
-
}, readOnly: true, onClick: onToggleAll, disabled: total === 0 }), _jsx("span", { children: count > 0 ? `${count} selected` : 'Select all' })] }), count > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: 'cbmg-bulkbar-spacer' }),
|
|
1489
|
+
}, readOnly: true, onClick: onToggleAll, disabled: total === 0 }), _jsx("span", { children: count > 0 ? `${count} selected` : 'Select all' })] }), count > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: 'cbmg-bulkbar-spacer' }), onTag ? (_jsx(Tooltip, { content: `Tag the ${count} selected item${count === 1 ? '' : 's'} in one go`, children: _jsx("button", {
|
|
1490
|
+
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
1491
|
+
// gallery's own plain CSS, matching its neighbors.
|
|
1492
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": `Tag the ${count} selected item${count === 1 ? '' : 's'}`, onClick: onTag, children: _jsx(Tags, { "aria-hidden": true }) }) })) : null, extra, onDelete ? (_jsx(DeleteButton, { size: 'sm', "aria-label": `Delete the ${count} selected item${count === 1 ? '' : 's'}`, tooltip: `Delete the ${count} selected item${count === 1 ? '' : 's'}`, confirmOptions: {
|
|
1119
1493
|
title: `Delete ${count} item${count === 1 ? '' : 's'}?`,
|
|
1120
1494
|
message: 'This cannot be undone.',
|
|
1121
|
-
}, onDelete: onDelete,
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1495
|
+
}, onDelete: onDelete,
|
|
1496
|
+
/* ๐ด The COUNT drops off the pill and loses nothing, because this bar prints
|
|
1497
|
+
"{count} selected" three controls to the left โ it was the one number in
|
|
1498
|
+
the strip that was already stated twice. Both the name and the tooltip
|
|
1499
|
+
above still carry it, so a reader hovering the trash still learns how many
|
|
1500
|
+
it takes. */
|
|
1501
|
+
iconOnly: true })) : null, _jsx(Tooltip, { content: 'Untick everything and leave the tiles alone', children: _jsx("button", {
|
|
1502
|
+
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
1503
|
+
// gallery's own plain CSS, matching its neighbors.
|
|
1504
|
+
type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Clear the selection', onClick: onClear, children: _jsx(X, { "aria-hidden": true }) }) })] })) : null] }));
|
|
1125
1505
|
}
|
|
1126
1506
|
//# sourceMappingURL=MediaGallery.js.map
|