Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57be521a2 | ||
|
|
792b834cbe |
@@ -16,7 +16,8 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
|
||||
- `npm test` / `npm run test:watch` — vitest
|
||||
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
|
||||
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
|
||||
files for every profile on disk (see `scripts/jd2-sync.ts`)
|
||||
files for every profile on disk (see `scripts/jd2-sync.ts` and
|
||||
`docs/jdownloader.md`)
|
||||
|
||||
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"private": true,
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
|
||||
+44
-9
@@ -17,7 +17,7 @@ import {
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
import { cn } from './lib/utils';
|
||||
import { PRESS } from './lib/motion';
|
||||
import { PRESS, prefersReducedMotion } from './lib/motion';
|
||||
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
|
||||
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
||||
import {
|
||||
@@ -111,6 +111,30 @@ export default function App() {
|
||||
|
||||
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Blurred backdrops behind the scanning UI, newest last.
|
||||
*
|
||||
* Each new image is stacked *over* the previous one and fades in; the one
|
||||
* underneath stays fully opaque until it's covered. Cross-fading by swapping
|
||||
* a single element left the pale backdrop showing through mid-transition,
|
||||
* which read as a white flash between every image.
|
||||
*/
|
||||
const [scanBackdrops, setScanBackdrops] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastLoadedScanningImage) return;
|
||||
setScanBackdrops(prev =>
|
||||
prev[prev.length - 1] === lastLoadedScanningImage
|
||||
? prev
|
||||
: [...prev, lastLoadedScanningImage].slice(-3),
|
||||
);
|
||||
}, [lastLoadedScanningImage]);
|
||||
|
||||
// Don't carry one archive's backdrops into the next scan.
|
||||
useEffect(() => {
|
||||
if (!isScanning) { setScanBackdrops([]); setLastLoadedScanningImage(null); }
|
||||
}, [isScanning]);
|
||||
|
||||
const {
|
||||
username,
|
||||
fullName,
|
||||
@@ -460,17 +484,28 @@ export default function App() {
|
||||
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.img
|
||||
key={lastLoadedScanningImage}
|
||||
src={lastLoadedScanningImage || undefined}
|
||||
{/*
|
||||
The 0.4 lives on the group, not the images: two layers overlap
|
||||
during a cross-fade, and fading them individually would darken the
|
||||
backdrop as they cross. Inside the group each layer goes to full
|
||||
opacity, so the stack is always completely covered.
|
||||
*/}
|
||||
<div className="absolute inset-0 z-0 opacity-40">
|
||||
{scanBackdrops.map(src => (
|
||||
<motion.img
|
||||
key={src}
|
||||
src={src}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={prefersReducedMotion() ? { duration: 0 } : { duration: 0.9, ease: 'easeInOut' }}
|
||||
onAnimationComplete={() => setScanBackdrops(prev => {
|
||||
// Once this layer is opaque it hides everything below it.
|
||||
const i = prev.indexOf(src);
|
||||
return i > 0 ? prev.slice(i) : prev;
|
||||
})}
|
||||
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-white/40 z-1" />
|
||||
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
||||
|
||||
@@ -82,11 +82,14 @@ export const PostModal: React.FC<PostModalProps> = ({
|
||||
|
||||
useEffect(() => setCurrentIndex(0), [post.id]);
|
||||
useEffect(() => {
|
||||
// Arrows page within the carousel — the thing the arrows visually point at.
|
||||
// Moving between posts stays on the side buttons, with , and . as keyboard
|
||||
// equivalents.
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight') goToPost(1, 'x');
|
||||
else if (e.key === 'ArrowLeft') goToPost(-1, 'x');
|
||||
else if (e.key === '.') paginate(1);
|
||||
else if (e.key === ',') paginate(-1);
|
||||
if (e.key === 'ArrowRight') paginate(1);
|
||||
else if (e.key === 'ArrowLeft') paginate(-1);
|
||||
else if (e.key === '.') goToPost(1, 'x');
|
||||
else if (e.key === ',') goToPost(-1, 'x');
|
||||
else if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
@@ -107,11 +107,23 @@ export const useArchiveScanner = (
|
||||
/** Stable identity for a media file, used to rehydrate URLs after a reload. */
|
||||
const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name;
|
||||
|
||||
/**
|
||||
* Decompress an `.xz` metadata sidecar.
|
||||
*
|
||||
* Read fully into memory first rather than handing the live HTTP body to
|
||||
* the decompressor. These sidecars are a few KB, so buffering costs
|
||||
* nothing, and streaming was actively harmful: the decompressor stops
|
||||
* reading at the end of the xz member, leaving the response body neither
|
||||
* drained nor cancelled. Across a couple of hundred sidecars that exhausts
|
||||
* the connection pool and every later fetch fails with "Failed to fetch" —
|
||||
* which silently cost Instaloader archives their captions, story flags and
|
||||
* profile metadata, since all of it lives in these files.
|
||||
*/
|
||||
const parseXZFile = async (file: ArchiveFile) => {
|
||||
try {
|
||||
const stream = new XzReadableStream(file.stream());
|
||||
const response = new Response(stream);
|
||||
return await response.json();
|
||||
const compressed = await file.arrayBuffer();
|
||||
const stream = new XzReadableStream(new Blob([compressed]).stream());
|
||||
return await new Response(stream).json();
|
||||
} catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; }
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export class LocalArchiveFile implements ArchiveFile {
|
||||
get size() { return this.file.size; }
|
||||
text() { return this.file.text(); }
|
||||
arrayBuffer() { return this.file.arrayBuffer(); }
|
||||
stream() { return this.file.stream(); }
|
||||
|
||||
/**
|
||||
* A blob: URL backed directly by the on-disk File.
|
||||
@@ -55,15 +54,6 @@ export class RemoteArchiveFile implements ArchiveFile {
|
||||
const res = await fetch(this.url);
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
stream() {
|
||||
const transform = new TransformStream();
|
||||
fetch(this.url).then(res => {
|
||||
if (res.body) res.body.pipeTo(transform.writable);
|
||||
else transform.writable.getWriter().close();
|
||||
});
|
||||
return transform.readable;
|
||||
}
|
||||
|
||||
createObjectUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface ArchiveFile {
|
||||
size: number;
|
||||
text(): Promise<string>;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
stream(): ReadableStream<Uint8Array>;
|
||||
url?: string;
|
||||
/**
|
||||
* A URL pointing at this file's contents. Local files mint a disk-backed
|
||||
|
||||
Reference in New Issue
Block a user