@kolkrabbi/kol-component 0.105.0 → 0.106.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.105.0",
3
+ "version": "0.106.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -142,6 +142,7 @@ export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
142
142
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
143
143
  export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
144
144
  export { default as KindPreview } from './molecules/KindPreview.jsx'
145
+ export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './molecules/AudioPreview.jsx'
145
146
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
146
147
  export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
147
148
  export { default as RecordManager } from './organisms/RecordManager.jsx'
@@ -0,0 +1,123 @@
1
+ /* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, not a ref read; the DS Tooltip wires it the same way */
2
+ import { useRef, useState } from 'react';
3
+ import IconFrame from '../atoms/IconFrame.jsx';
4
+ import Slider from './Slider.jsx';
5
+ import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
6
+
7
+ /* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
8
+
9
+ /**
10
+ * AudioPreview · AudioTile · VideoTile — kol-r2b2's `AudioPreview.jsx`, promoted
11
+ * verbatim 2026-08-27 (ColumnBrowserMediaFacts): players composed from DS parts
12
+ * (IconFrame · Slider · Popover) — the DS AudioPlayer is a native <audio controls>,
13
+ * whose one-row layout can't be reshaped. Finder model (user ruling 2026-08-27):
14
+ * the COLUMN gets a square tile with one play/pause control and nothing else —
15
+ * `AudioTile` / `VideoTile` (KindPreview's audio and video branches); timeline +
16
+ * volume belong to the OVERLAY / Quick Look — `AudioPreview` (play/pause ·
17
+ * Slider seek with an m:ss readout · volume behind `slider-01`, a vertical
18
+ * `slider-black` range in a PopoverPanel, placement top; no title line — the
19
+ * name sits in the facts).
20
+ *
21
+ * @param {string} src the media URL
22
+ * @param {string} poster VideoTile — the poster URL
23
+ * @param {Function} onDuration AudioPreview — (seconds) => void once metadata lands
24
+ * @param {string} className AudioPreview — wrapper classes (the width is the consumer's)
25
+ */
26
+ export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
27
+ const fmt = formatLength;
28
+
29
+ function useAudio(onDuration) {
30
+ const ref = useRef(null);
31
+ const [playing, setPlaying] = useState(false);
32
+ const [time, setTime] = useState(0);
33
+ const [duration, setDuration] = useState(0);
34
+ const toggle = () => (playing ? ref.current.pause() : ref.current.play());
35
+ const element = (src) => (
36
+ <audio
37
+ ref={ref}
38
+ src={src}
39
+ preload="metadata"
40
+ onPlay={() => setPlaying(true)}
41
+ onPause={() => setPlaying(false)}
42
+ onEnded={() => setPlaying(false)}
43
+ onTimeUpdate={(e) => setTime(e.target.currentTime)}
44
+ onLoadedMetadata={(e) => { setDuration(e.target.duration); onDuration?.(e.target.duration); }}
45
+ />
46
+ );
47
+ return { ref, playing, time, duration, toggle, element, setTime };
48
+ }
49
+
50
+ export function AudioTile({ src }) {
51
+ const { playing, toggle, element } = useAudio();
52
+ return (
53
+ <div className="w-full aspect-square flex items-center justify-center">
54
+ {element(src)}
55
+ <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" onClick={toggle} />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // Video tile: covers the square, one play/pause control — native controls stay in the overlay.
61
+ export function VideoTile({ src, poster }) {
62
+ const ref = useRef(null);
63
+ const [playing, setPlaying] = useState(false);
64
+ return (
65
+ <div className="relative w-full aspect-square rounded overflow-hidden">
66
+ <video
67
+ ref={ref}
68
+ src={src}
69
+ poster={poster}
70
+ playsInline
71
+ preload="metadata"
72
+ className="w-full h-full object-cover"
73
+ onPlay={() => setPlaying(true)}
74
+ onPause={() => setPlaying(false)}
75
+ onEnded={() => setPlaying(false)}
76
+ />
77
+ <div className="absolute inset-0 flex items-center justify-center">
78
+ <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ export default function AudioPreview({ src, className = '', onDuration }) {
85
+ const { ref, playing, time, duration, toggle, element, setTime } = useAudio(onDuration);
86
+ const [volume, setVolume] = useState(100);
87
+ const [open, setOpen] = useState(false);
88
+ const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' });
89
+
90
+ return (
91
+ <div className={`flex items-center gap-2 ${className}`}>
92
+ {element(src)}
93
+ <IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={toggle} />
94
+ <Slider
95
+ className="flex-1"
96
+ min={0}
97
+ max={duration || 0}
98
+ step={0.1}
99
+ value={time}
100
+ onChange={(v) => { ref.current.currentTime = v; setTime(v); }}
101
+ formatValue={fmt}
102
+ displayWidth={5}
103
+ />
104
+ <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
105
+ <IconFrame name="slider-01" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} />
106
+ </span>
107
+ <PopoverPanel popover={pop} className="p-2">
108
+ {/* DS track class on a native range, turned upright: the one thing the DS Slider can't do. */}
109
+ <div className="w-6 h-24 flex items-center justify-center">
110
+ <input
111
+ type="range"
112
+ min={0}
113
+ max={100}
114
+ value={volume}
115
+ onChange={(e) => { const v = Number(e.target.value); ref.current.volume = v / 100; setVolume(v); }}
116
+ className="slider-black cursor-pointer w-24 -rotate-90"
117
+ aria-label="Volume"
118
+ />
119
+ </div>
120
+ </PopoverPanel>
121
+ </div>
122
+ );
123
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import AudioPlayer from '../atoms/AudioPlayer.jsx'
2
+ import { AudioTile, VideoTile } from './AudioPreview.jsx'
3
3
  import HlsVideo from '../atoms/HlsVideo.jsx'
4
4
  import CodeBlock from './CodeBlock.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
@@ -13,7 +13,10 @@ import markdownToHtml from '../utilities/markdownToHtml.js'
13
13
  * promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
14
14
  * that was not an image or a video showed a grey box with the word "text".
15
15
  * HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
16
- * audio → `AudioPlayer`, markdownrendered prose in `.kol-prose`
16
+ * video → `VideoTile` and audio `AudioTile` (ColumnBrowserMediaFacts
17
+ * 2026-08-27 — the square column tile with one play/pause control; no Figure, no
18
+ * border — `VideoBlock`'s Figure border is why kol-r2b2 bypassed it; the overlay
19
+ * player is `AudioPreview`), markdown → rendered prose in `.kol-prose`
17
20
  * (markdownToHtml, KindPreviewMarkdown 2026-08-27), json / yaml / text / code →
18
21
  * `CodeBlock` (language by kind or extension), the rest
19
22
  * → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
@@ -71,13 +74,8 @@ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf =
71
74
  </div>
72
75
  )
73
76
  }
74
- if (kind === 'audio') {
75
- return (
76
- <div className="flex justify-center p-8">
77
- <AudioPlayer src={url} label={name} className="items-center w-[420px] max-w-full" />
78
- </div>
79
- )
80
- }
77
+ if (kind === 'video') return <VideoTile src={url} poster={poster} />
78
+ if (kind === 'audio') return <AudioTile src={url} />
81
79
  if (isText) {
82
80
  if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
83
81
  if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import KindPreview from '../molecules/KindPreview.jsx'
4
+ import { formatLength } from '../molecules/AudioPreview.jsx'
4
5
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
5
6
 
6
7
  /**
@@ -43,7 +44,7 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
43
44
  * @param {Object} kindLabel kind → label shown when there is no visual preview
44
45
  * @param {Function} formatSize (bytes) => string
45
46
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
46
- * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay); without it images render the organism's <img>, everything else the DS KindPreview
47
+ * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
47
48
  * @param {string} className extra classes on the browser
48
49
  */
49
50
 
@@ -96,14 +97,21 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
96
97
  }
97
98
 
98
99
  function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
99
- // Pixel size comes from the loaded image itself — the bucket stores none.
100
+ // Pixel size and length come from the loaded media itself — the bucket stores
101
+ // none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
102
+ // `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
103
+ // 2026-08-27 — "missing pixel dimensions and length in info").
100
104
  const [dims, setDims] = useState(null)
105
+ const kind = kindOf(o)
101
106
  const src = isImage(o) ? urlOf?.(o) : null
107
+ const sized = src || kind === 'video'
108
+ const timed = kind === 'video' || kind === 'audio'
102
109
  const facts = [
103
- ['Kind', kindLabel[kindOf(o)] || 'file'],
110
+ ['Kind', kindLabel[kind] || 'file'],
104
111
  ['Type', o.contentType || '—'],
105
112
  ['Size', formatSize(o.size)],
106
- ...(src ? [['Dimensions', dims ? `${dims.w} × ${dims.h} px` : '…']] : []),
113
+ ...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : '…']] : []),
114
+ ...(timed ? [['Length', dims?.len != null ? formatLength(dims.len) : '…']] : []),
107
115
  ['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
108
116
  ]
109
117
  return (
@@ -111,7 +119,9 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
111
119
  {/* the media frame: an image is the organism's own <img> (it reads the
112
120
  * dimensions); anything else is `renderPreview(o)` or the DS KindPreview
113
121
  * (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
114
- * 2026-08-27). Dimensions also come off any <img> a custom node loads. */}
122
+ * 2026-08-27). Dimensions and Length also come off any <img> / <video> /
123
+ * <audio> a custom node loads — captured on the frame, so consumer
124
+ * `renderPreview` nodes count. */}
115
125
  {src && !renderPreview ? (
116
126
  <div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
117
127
  <img
@@ -126,6 +136,11 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
126
136
  <div
127
137
  className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-fg-04 flex items-center justify-center"
128
138
  onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
139
+ onLoadedMetadataCapture={(e) => {
140
+ const t = e.target
141
+ if (t?.tagName === 'VIDEO') setDims({ w: t.videoWidth, h: t.videoHeight, len: t.duration })
142
+ else if (t?.tagName === 'AUDIO') setDims({ len: t.duration })
143
+ }}
129
144
  >
130
145
  {renderPreview ? renderPreview(o) : <KindPreview o={o} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} />}
131
146
  </div>