mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 11:15:41 +02:00
feat: entry previews (video, tweet, article, iframe, image, audio bar)
- Add PreviewPanel dispatch hub routing by entity_kind + artifact extension - VideoPreview: HTML5 <video> for YouTube/Instagram/TikTok/Reddit/X posts - TweetPreview: tweet card, thread, and X article renderer (ported from x-article-renderer) - IframePreview: sandboxed iframe for SingleFile web pages and PDFs - ImagePreview: image viewer with click-to-open-fullsize - AudioBar: persistent fixed-bottom player (Spotify-style) that survives entry navigation - Lift entryDetail to App.jsx, shared between PreviewPanel and ContextRail - 3-column layout (workspace | 300px preview | 340px rail) when preview active - Stale-guard fixes: seq incremented before early returns in all async effects - handleRearchive: capture startSeq/entryUid at call time, guard every async resume - TweetPreview: reset loading/error/tweets before early-return branches
This commit is contained in:
parent
2779afee2d
commit
5de9e291b0
13 changed files with 1974 additions and 69 deletions
223
frontend/src/components/AudioBar.jsx
Normal file
223
frontend/src/components/AudioBar.jsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { sourceIconSvg } from '../utils';
|
||||
|
||||
function formatTime(secs) {
|
||||
if (!isFinite(secs) || isNaN(secs)) return '--:--';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function AudioBar({ entry, src, archiveId, onClose }) {
|
||||
const audioRef = useRef(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(NaN);
|
||||
const [volume, setVolume] = useState(1.0);
|
||||
|
||||
// Auto-play whenever src changes
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || !src) return;
|
||||
audioRef.current.load();
|
||||
audioRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch(() => {
|
||||
// Autoplay blocked — silently remain paused
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setCurrentTime(0);
|
||||
setDuration(NaN);
|
||||
}, [src]);
|
||||
|
||||
// Sync volume to audio element
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
function handlePlayPause() {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeek(e) {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !isFinite(duration)) return;
|
||||
const t = Number(e.target.value);
|
||||
audio.currentTime = t;
|
||||
setCurrentTime(t);
|
||||
}
|
||||
|
||||
function handleVolumeChange(e) {
|
||||
setVolume(Number(e.target.value));
|
||||
}
|
||||
|
||||
const title = entry?.title || entry?.entry_uid || 'Unknown';
|
||||
const kind = entry?.source_kind || 'other';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden audio element */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src || undefined}
|
||||
preload="auto"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? NaN)}
|
||||
onDurationChange={() => setDuration(audioRef.current?.duration ?? NaN)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Fixed bottom bar */}
|
||||
<div
|
||||
className="audio-bar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
background: 'var(--paper-3)',
|
||||
borderTop: '1px solid var(--line)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
padding: '0 16px',
|
||||
height: '56px',
|
||||
fontFamily: 'var(--sans)',
|
||||
}}
|
||||
>
|
||||
{/* Left: icon + title */}
|
||||
<div
|
||||
className="audio-bar-info"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 0,
|
||||
flex: '0 1 220px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="source-icon"
|
||||
style={{ flexShrink: 0, width: '18px', height: '18px', display: 'flex', alignItems: 'center' }}
|
||||
dangerouslySetInnerHTML={{ __html: sourceIconSvg(kind) }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center: play/pause + seek + time */}
|
||||
<div
|
||||
className="audio-bar-controls"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: 'var(--ink)',
|
||||
flexShrink: 0,
|
||||
fontSize: '1.2rem',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={isFinite(duration) ? duration : 0}
|
||||
step={0.1}
|
||||
value={isFinite(currentTime) ? currentTime : 0}
|
||||
onChange={handleSeek}
|
||||
aria-label="Seek"
|
||||
style={{ flex: 1, minWidth: 0, accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: volume + close */}
|
||||
<div
|
||||
className="audio-bar-right"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: '0 1 160px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--muted)', flexShrink: 0 }}>🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
aria-label="Volume"
|
||||
style={{ width: '80px', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close audio player"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: 'var(--muted)',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1,
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue