Security - Fix path traversal in GET /api/archives/:name/files. Express decodes route params after segment matching, so `..%2f..%2fetc` escaped ARCHIVES_DIR and returned a recursive listing of arbitrary directories. - Add CSP and baseline security headers; disable x-powered-by. - Stop baking GEMINI_API_KEY into the client bundle (the SDK was unused). - Run the container as `node` instead of root. Performance - Add a directory-mtime-keyed archive index, warmed in the background and persisted. Listing 110k files went from ~52s to ~0.1s; the largest archive (24k files) serves in ~0.3s. Per-file stat over CIFS costs ~1.4ms and does not parallelise, so it is now done once rather than per request. - Build media URLs from the File directly instead of `new Blob([await file.arrayBuffer()])`, which read every media file fully into memory (a 20GB archive tried to become 20GB of resident blobs). - Track and revoke object URLs; previously none were ever revoked. - Give `requestThumbnail` a stable identity so a completed thumbnail stops re-running the effect in every mounted thumbnail. - Namespace IndexedDB keys so listing archives no longer deserializes every cached thumbnail blob, and thumbnails no longer collide across archives. - Serve real file sizes: RemoteArchiveFile was constructed with size 0, which silently disabled high-res thumbnailing for every server archive. Correctness - Local archives cached media as blob: URLs, which die with the document, so a cached local archive restored as an archive of broken images. Media now carries a stable path and is rehydrated from a persisted directory handle (File System Access API), falling back to re-prompting for the folder. - Fix permalinks: the URL-writing effect erased ?a= on mount before the archive list arrived to consume it, so deep links never resolved. - Make cache invalidation detect nested changes via a directory signature. - Add an error boundary and tolerate unparseable dates, which previously threw a RangeError and blanked the app. - Default video to muted so autoplay is not blocked by Safari/Firefox. Features - Fold sidecar directories into their base profile: `<user> - reels`, `story - <user>` and `story highlights - <user> - <title>` now appear as reels, the story ring and Instagram-style highlight circles rather than as separate archives. Housekeeping - Add @types/react; React was previously type-checked against its JavaScript source, so `npm run lint` gave almost no type safety on components. - Vendor fonts and PWA icons locally; the app made third-party CDN requests despite advertising offline support and local-only processing. - Drop unused better-sqlite3 (a native module that broke `npm install`). - Add vitest with 36 tests over the filename and directory-naming rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Volume2,
|
|
VolumeX,
|
|
X
|
|
} from 'lucide-react';
|
|
import { motion } from 'motion/react';
|
|
import { Post } from '../types';
|
|
import { cn, formatDateSafe } from '../lib/utils';
|
|
|
|
interface StoryViewerProps {
|
|
stories: Post[];
|
|
onClose: () => void;
|
|
profilePic: string | null;
|
|
/** Highlight name, shown in place of the date when viewing a highlight. */
|
|
title?: string;
|
|
}
|
|
|
|
export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|
stories,
|
|
onClose,
|
|
profilePic,
|
|
title
|
|
}) => {
|
|
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
|
const [progress, setProgress] = useState(0);
|
|
// Start muted: Safari and Firefox refuse to autoplay audible media, which
|
|
// would stall the reel on its first video.
|
|
const [isMuted, setIsMuted] = useState(true);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const story = stories[currentStoryIndex];
|
|
const primary = story?.media?.[0];
|
|
|
|
useEffect(() => {
|
|
setProgress(0);
|
|
let duration = 5000;
|
|
const interval = 50;
|
|
|
|
const updateProgress = () => {
|
|
if (primary?.type === 'video' && videoRef.current) {
|
|
const currentTime = videoRef.current.currentTime;
|
|
const totalTime = videoRef.current.duration;
|
|
if (totalTime) {
|
|
setProgress((currentTime / totalTime) * 100);
|
|
}
|
|
} else {
|
|
setProgress(prev => {
|
|
const step = (interval / duration) * 100;
|
|
if (prev >= 100) return 100;
|
|
return prev + step;
|
|
});
|
|
}
|
|
};
|
|
|
|
const timer = setInterval(() => {
|
|
updateProgress();
|
|
}, interval);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [currentStoryIndex, primary]);
|
|
|
|
useEffect(() => {
|
|
if (progress >= 100) {
|
|
if (currentStoryIndex < stories.length - 1) {
|
|
setCurrentStoryIndex(prev => prev + 1);
|
|
} else {
|
|
onClose();
|
|
}
|
|
}
|
|
}, [progress, currentStoryIndex, stories.length, onClose]);
|
|
|
|
const nextStory = () => {
|
|
if (currentStoryIndex < stories.length - 1) {
|
|
setCurrentStoryIndex(prev => prev + 1);
|
|
} else {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const prevStory = () => {
|
|
if (currentStoryIndex > 0) {
|
|
setCurrentStoryIndex(prev => prev - 1);
|
|
}
|
|
};
|
|
|
|
// An empty or exhausted reel has nothing to show; bail before dereferencing.
|
|
if (!story || !primary) return null;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
|
onClick={onClose}
|
|
>
|
|
<div className="absolute inset-0 z-0 text-white">
|
|
<img
|
|
src={primary.url}
|
|
alt=""
|
|
className="w-full h-full object-cover blur-3xl opacity-30"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); prevStory(); }}
|
|
className={cn(
|
|
"hidden md:flex absolute left-4 lg:left-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md",
|
|
currentStoryIndex === 0 && "opacity-0 pointer-events-none"
|
|
)}
|
|
>
|
|
<ChevronLeft size={32} strokeWidth={1.5} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); nextStory(); }}
|
|
className="hidden md:flex absolute right-4 lg:right-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md"
|
|
>
|
|
<ChevronRight size={32} strokeWidth={1.5} />
|
|
</button>
|
|
|
|
<div
|
|
className="relative w-full h-full md:h-[90vh] md:max-w-[45vh] bg-black overflow-hidden md:rounded-lg shadow-2xl z-10 text-white"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="absolute top-2 left-2 right-2 z-50 flex px-1 text-white"
|
|
style={{ gap: stories.length > 100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }}
|
|
>
|
|
{stories.map((_, i) => (
|
|
<div key={i} className="h-1 flex-1 bg-white/20 rounded-full overflow-hidden text-white">
|
|
<div
|
|
className="h-full bg-white transition-all duration-75 text-white"
|
|
style={{
|
|
width: i < currentStoryIndex ? '100%' : (i === currentStoryIndex ? `${progress}%` : '0%')
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="absolute top-6 left-4 right-4 z-50 flex items-center justify-between text-white">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-white/10 p-0.5">
|
|
<div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
|
{profilePic ? (
|
|
<img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" />
|
|
) : (
|
|
<span className="text-[10px] font-bold text-black uppercase">{story.username[0]}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-white">
|
|
<span className="text-xs font-semibold">{story.username}</span>
|
|
{title && <span className="text-[10px] opacity-80 font-medium truncate max-w-[120px]">{title}</span>}
|
|
<span className="text-[10px] opacity-60 font-medium">{formatDateSafe(story.date, 'MMM d')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 text-white">
|
|
{primary.type === 'video' && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
|
|
className="p-2 hover:bg-white/10 rounded-full transition-colors text-white"
|
|
>
|
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
|
</button>
|
|
)}
|
|
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-white">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full h-full flex items-center justify-center pointer-events-none text-white">
|
|
{primary.type === 'video' ? (
|
|
<video
|
|
ref={videoRef}
|
|
src={primary.url}
|
|
className="w-full h-full object-contain"
|
|
autoPlay
|
|
muted={isMuted}
|
|
playsInline
|
|
controls
|
|
onEnded={nextStory}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={primary.url}
|
|
alt=""
|
|
className="w-full h-full object-contain"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="absolute inset-0 z-20 flex">
|
|
<div className="w-1/4 h-full cursor-pointer" onClick={prevStory} title="Previous Story" />
|
|
<div className="w-3/4 h-full cursor-pointer" onClick={nextStory} title="Next Story" />
|
|
</div>
|
|
|
|
{story.caption && (
|
|
<div className="absolute bottom-16 left-4 right-4 z-50 bg-black/20 backdrop-blur-sm p-3 rounded-lg text-white text-xs text-center border border-white/10">
|
|
{story.caption}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|