From e23dfe447499c30ec9ce0238f29888003a744659 Mon Sep 17 00:00:00 2001 From: ergosteur <1992147+ergosteur@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:59:31 -0500 Subject: [PATCH] feat: implement self-hostable mode with server-side directory scanning --- package.json | 1 + server.ts | 107 ++++++++++++++ src/App.tsx | 384 ++++++++++++++++++++++++++++++++++--------------- vite.config.ts | 6 +- 4 files changed, 383 insertions(+), 115 deletions(-) create mode 100644 server.ts diff --git a/package.json b/package.json index 4cf002d..144f330 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite --port=3000 --host=0.0.0.0", "build": "vite build", "preview": "vite preview", + "server": "tsx server.ts", "clean": "rm -rf dist", "lint": "tsc --noEmit" }, diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..48ed45a --- /dev/null +++ b/server.ts @@ -0,0 +1,107 @@ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3001; +const ARCHIVES_DIR = process.env.ARCHIVES_DIR || path.join(__dirname, '_sample-archives'); + +// Ensure archives directory exists +if (!fs.existsSync(ARCHIVES_DIR)) { + console.warn(`Warning: Archives directory not found at ${ARCHIVES_DIR}. Creating it...`); + fs.mkdirSync(ARCHIVES_DIR, { recursive: true }); +} + +app.use(express.json()); + +// API: List archives (subdirectories in ARCHIVES_DIR) +app.get('/api/archives', (req, res) => { + try { + const items = fs.readdirSync(ARCHIVES_DIR, { withFileTypes: true }); + const archives = items + .filter(item => item.isDirectory()) + .map(item => { + // Try to find a profile pic or first image for the thumbnail + const archivePath = path.join(ARCHIVES_DIR, item.name); + const files = fs.readdirSync(archivePath); + + let thumbnail = ''; + const profilePic = files.find(f => f.toLowerCase().includes('_profile_pic.jpg') || f.toLowerCase() === `${item.name.toLowerCase()}.jpg`); + if (profilePic) { + thumbnail = `/archives/${item.name}/${profilePic}`; + } else { + const firstImage = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f)); + if (firstImage) thumbnail = `/archives/${item.name}/${firstImage}`; + } + + return { + name: item.name, + thumbnail, + path: item.name, + fileCount: files.length + }; + }); + res.json(archives); + } catch (err) { + console.error('Error listing archives:', err); + res.status(500).json({ error: 'Failed to list archives' }); + } +}); + +// API: List all files in an archive (recursive) +app.get('/api/archives/:name/files', (req, res) => { + const archiveName = req.params.name; + const archivePath = path.join(ARCHIVES_DIR, archiveName); + + if (!fs.existsSync(archivePath)) { + return res.status(404).json({ error: 'Archive not found' }); + } + + try { + const walk = (dir: string, base: string = ''): string[] => { + let results: string[] = []; + const list = fs.readdirSync(dir); + list.forEach(file => { + const filePath = path.join(dir, file); + const relativePath = path.join(base, file); + const stat = fs.statSync(filePath); + if (stat && stat.isDirectory()) { + results = results.concat(walk(filePath, relativePath)); + } else { + results.push(relativePath); + } + }); + return results; + }; + + const files = walk(archivePath); + res.json(files); + } catch (err) { + console.error('Error listing files:', err); + res.status(500).json({ error: 'Failed to list files' }); + } +}); + +// Serve archive files +app.use('/archives', express.static(ARCHIVES_DIR)); + +// Serve production frontend +const distPath = path.join(__dirname, 'dist'); +if (fs.existsSync(distPath)) { + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); +} + +app.listen(PORT, () => { + console.log(`Server running at http://localhost:${PORT}`); + console.log(`Serving archives from: ${ARCHIVES_DIR}`); +}); diff --git a/src/App.tsx b/src/App.tsx index c5844d3..024c46c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ function cn(...inputs: ClassValue[]) { } // --- Types --- + interface MediaFile { name: string; url: string; @@ -50,8 +51,130 @@ interface Post { isStory?: boolean; } +/** + * Common interface for both local File objects and remote server-side files. + */ +interface ArchiveFile { + name: string; + webkitRelativePath: string; + size: number; + text(): Promise; + arrayBuffer(): Promise; + stream(): ReadableStream; + url?: string; +} + +class LocalArchiveFile implements ArchiveFile { + constructor(private file: File) {} + get name() { return this.file.name; } + get webkitRelativePath() { return this.file.webkitRelativePath; } + get size() { return this.file.size; } + text() { return this.file.text(); } + arrayBuffer() { return this.file.arrayBuffer(); } + stream() { return this.file.stream(); } +} + +class RemoteArchiveFile implements ArchiveFile { + constructor( + public name: string, + public webkitRelativePath: string, + public size: number, + public url: string + ) {} + async text() { + const res = await fetch(this.url); + return res.text(); + } + async arrayBuffer() { + 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; + } +} + +interface ServerArchive { + name: string; + thumbnail: string; + path: string; + fileCount: number; +} + // --- Components --- +const ArchiveDashboard = ({ + archives, + onSelect, + onLocalSelect, + isScanning +}: { + archives: ServerArchive[]; + onSelect: (archive: ServerArchive) => void; + onLocalSelect: () => void; + isScanning: boolean; +}) => { + return ( +
+
+

Your Archives

+

+ Select a hosted archive to browse or upload a local directory from your computer. +

+
+ +
+ {/* Local Upload Card */} + + + {/* Server Archives */} + {archives.map((archive) => ( + + ))} +
+
+ ); +}; + const StoryViewer = ({ stories, onClose, @@ -266,7 +389,7 @@ const VideoThumbnail = ({ url, className }: { url: string; className?: string }) observer.disconnect(); } }, - { rootMargin: '200px' } // Start loading before it's actually in view + { rootMargin: '200px' } ); observer.observe(containerRef.current); @@ -426,7 +549,6 @@ const PostModal = ({ const [currentIndex, setCurrentIndex] = useState(0); const [direction, setDirection] = useState(0); - // Reset currentIndex when post changes useEffect(() => { setCurrentIndex(0); }, [post.id]); @@ -483,8 +605,8 @@ const PostModal = ({ }) }; - const swipeConfidenceThreshold = 15000; // Increased from 10000 for more intentional swipes - const interPostSwipeThreshold = 40000; // Higher threshold for switching between different posts + const swipeConfidenceThreshold = 15000; + const interPostSwipeThreshold = 40000; const swipePower = (offset: number, velocity: number) => { return Math.abs(offset) * velocity; }; @@ -506,7 +628,6 @@ const PostModal = ({ - {/* Post Navigation Buttons - Hidden on Mobile */} {hasPrevPost && onPrevPost && (
- {allPosts.length === 0 ? ( -
-
- + {allPosts.length === 0 && !isScanning ? ( + isServerMode ? ( + + ) : ( +
+
+ +
+
+

No Archive Loaded

+

+ Select the directory containing your Instagram archive files to start browsing. +

+
+
-
-

No Archive Loaded

-

- Select the directory containing your Instagram archive files to start browsing. -

-
- + ) + ) : isScanning ? ( +
+ +
Scanning Archive...
+

Parsing media and metadata

) : (
@@ -1409,14 +1566,13 @@ export default function App() { ))}
- {/* Pagination Button */} - {allPosts.length > visiblePostsCount && ( -
+ {filteredPosts.length > visiblePostsCount && ( +
)} @@ -1424,7 +1580,7 @@ export default function App() { )}
- {/* Post Detail Modal */} + {/* Post Modal */} {selectedPost && ( { }, server: { // HMR is disabled in AI Studio via DISABLE_HMR env var. - // Do not modify—file watching is disabled to prevent flickering during agent edits. + // Do not modify—file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true', + proxy: { + '/api': 'http://localhost:3001', + '/archives': 'http://localhost:3001', + }, }, }; });