|
|
from flask import Flask, jsonify |
|
|
import os |
|
|
|
|
|
app = Flask(__name__) |
|
|
|
|
|
LINKS_FILE = "links.txt" |
|
|
|
|
|
state = {"status": "idle", "images": []} |
|
|
|
|
|
def load_links(): |
|
|
links = [] |
|
|
if os.path.exists(LINKS_FILE): |
|
|
with open(LINKS_FILE, 'r', encoding='utf-8') as f: |
|
|
for line in f: |
|
|
line = line.strip() |
|
|
if line and not line.startswith('#'): |
|
|
links.append(line) |
|
|
return links |
|
|
|
|
|
def init_images(): |
|
|
global state |
|
|
links = load_links() |
|
|
state = {"status": "complete" if links else "error", "images": links} |
|
|
|
|
|
HTML_PAGE = '''<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
|
|
<meta name="referrer" content="no-referrer"> |
|
|
<title>EPSTEIN FILES</title> |
|
|
<style> |
|
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
|
|
|
|
body { |
|
|
background: #0a0a0a; |
|
|
font-family: 'Courier New', monospace; |
|
|
color: #fff; |
|
|
} |
|
|
|
|
|
#header { |
|
|
background: #000; |
|
|
color: #fff; |
|
|
text-align: center; |
|
|
padding: 20px; |
|
|
font-size: 24px; |
|
|
font-weight: 800; |
|
|
letter-spacing: 10px; |
|
|
position: sticky; |
|
|
top: 0; |
|
|
z-index: 100; |
|
|
border-bottom: 3px solid #fff; |
|
|
} |
|
|
|
|
|
.banner { |
|
|
background: #fff; |
|
|
color: #000; |
|
|
text-align: center; |
|
|
padding: 6px; |
|
|
font-size: 10px; |
|
|
font-weight: 700; |
|
|
letter-spacing: 3px; |
|
|
} |
|
|
|
|
|
#loading { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
min-height: calc(100vh - 120px); |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
border: 3px solid #333; |
|
|
border-top-color: #fff; |
|
|
border-radius: 50%; |
|
|
animation: spin 0.6s linear infinite; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } } |
|
|
|
|
|
#loading-text { |
|
|
color: #888; |
|
|
font-size: 12px; |
|
|
letter-spacing: 2px; |
|
|
} |
|
|
|
|
|
.error { color: #f44 !important; } |
|
|
|
|
|
#gallery { |
|
|
display: none; |
|
|
padding: 15px; |
|
|
} |
|
|
|
|
|
.gallery-header { |
|
|
text-align: center; |
|
|
margin-bottom: 15px; |
|
|
padding-bottom: 15px; |
|
|
border-bottom: 1px solid #333; |
|
|
} |
|
|
|
|
|
.gallery-header h2 { |
|
|
font-size: 28px; |
|
|
letter-spacing: 3px; |
|
|
} |
|
|
|
|
|
.gallery-header span { |
|
|
color: #666; |
|
|
font-size: 12px; |
|
|
} |
|
|
|
|
|
.gallery-header p { |
|
|
color: #555; |
|
|
font-size: 10px; |
|
|
margin-top: 8px; |
|
|
letter-spacing: 1px; |
|
|
} |
|
|
|
|
|
#grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
.item { |
|
|
aspect-ratio: 1; |
|
|
background: #111; |
|
|
border: 2px solid #333; |
|
|
position: relative; |
|
|
cursor: pointer; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.item img { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
object-fit: cover; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.item .num { |
|
|
position: absolute; |
|
|
bottom: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
background: rgba(0,0,0,0.85); |
|
|
color: #fff; |
|
|
font-size: 9px; |
|
|
padding: 4px; |
|
|
text-align: center; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.item .placeholder { |
|
|
position: absolute; |
|
|
inset: 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
color: #333; |
|
|
font-size: 10px; |
|
|
} |
|
|
|
|
|
#load-trigger { |
|
|
height: 60px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
#load-text { |
|
|
color: #555; |
|
|
font-size: 10px; |
|
|
letter-spacing: 2px; |
|
|
} |
|
|
|
|
|
#scroll-top { |
|
|
position: fixed; |
|
|
bottom: 15px; |
|
|
right: 15px; |
|
|
background: #000; |
|
|
border: 2px solid #fff; |
|
|
color: #fff; |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
font-size: 16px; |
|
|
cursor: pointer; |
|
|
z-index: 50; |
|
|
display: none; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
#scroll-top.show { display: flex; } |
|
|
|
|
|
#lightbox { |
|
|
position: fixed; |
|
|
inset: 0; |
|
|
background: #000; |
|
|
z-index: 300; |
|
|
display: none; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
#lightbox.open { display: flex; } |
|
|
|
|
|
.lb-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 10px 15px; |
|
|
border-bottom: 2px solid #fff; |
|
|
} |
|
|
|
|
|
.lb-counter { |
|
|
font-size: 14px; |
|
|
font-weight: 700; |
|
|
letter-spacing: 2px; |
|
|
} |
|
|
|
|
|
.lb-nav { display: flex; gap: 8px; } |
|
|
|
|
|
.lb-btn { |
|
|
background: none; |
|
|
border: 2px solid #fff; |
|
|
color: #fff; |
|
|
padding: 6px 12px; |
|
|
font-size: 14px; |
|
|
cursor: pointer; |
|
|
font-family: inherit; |
|
|
} |
|
|
|
|
|
.lb-body { |
|
|
flex: 1; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
padding: 10px; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
#lb-img { |
|
|
max-width: 100%; |
|
|
max-height: 100%; |
|
|
object-fit: contain; |
|
|
border: 2px solid #fff; |
|
|
} |
|
|
|
|
|
#lb-loader { |
|
|
position: absolute; |
|
|
color: #666; |
|
|
font-size: 12px; |
|
|
} |
|
|
|
|
|
@media (max-width: 600px) { |
|
|
#header { font-size: 16px; letter-spacing: 5px; padding: 15px; } |
|
|
.banner { font-size: 8px; padding: 4px; } |
|
|
#grid { grid-template-columns: repeat(3, 1fr); gap: 6px; } |
|
|
.gallery-header h2 { font-size: 22px; } |
|
|
.item .num { font-size: 8px; padding: 3px; } |
|
|
.lb-btn { padding: 5px 10px; font-size: 12px; } |
|
|
} |
|
|
|
|
|
@media (max-width: 380px) { |
|
|
#grid { grid-template-columns: repeat(2, 1fr); } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="header">EPSTEIN FILES</div> |
|
|
<div class="banner">★ ALL IMAGES ★</div> |
|
|
|
|
|
<div id="loading"> |
|
|
<div class="spinner"></div> |
|
|
<div id="loading-text">Loading...</div> |
|
|
</div> |
|
|
|
|
|
<div id="gallery"> |
|
|
<div class="gallery-header"> |
|
|
<h2 id="total-count">0</h2> |
|
|
<span>IMAGES</span> |
|
|
<p>Click to enlarge</p> |
|
|
</div> |
|
|
<div id="grid"></div> |
|
|
<div id="load-trigger"> |
|
|
<div id="load-text">SCROLL FOR MORE</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button id="scroll-top">▲</button> |
|
|
|
|
|
<div id="lightbox"> |
|
|
<div class="lb-header"> |
|
|
<div class="lb-counter" id="lb-counter">1 / 1</div> |
|
|
<div class="lb-nav"> |
|
|
<button class="lb-btn" id="lb-prev">◄</button> |
|
|
<button class="lb-btn" id="lb-next">►</button> |
|
|
<button class="lb-btn" id="lb-close">✕</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="lb-body" id="lb-body"> |
|
|
<div id="lb-loader">Loading...</div> |
|
|
<img id="lb-img" src="" alt="" referrerpolicy="no-referrer"> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let allImages = []; |
|
|
let loadedCount = 0; |
|
|
let currentIdx = 0; |
|
|
let isLoading = false; |
|
|
const BATCH = 60; |
|
|
const BUFFER = 800; // pixels above/below viewport to keep loaded |
|
|
|
|
|
const grid = document.getElementById('grid'); |
|
|
const lightbox = document.getElementById('lightbox'); |
|
|
const lbImg = document.getElementById('lb-img'); |
|
|
const lbLoader = document.getElementById('lb-loader'); |
|
|
const scrollBtn = document.getElementById('scroll-top'); |
|
|
const loadText = document.getElementById('load-text'); |
|
|
|
|
|
// Load a batch of items |
|
|
function loadBatch() { |
|
|
if (isLoading || loadedCount >= allImages.length) return; |
|
|
isLoading = true; |
|
|
|
|
|
const end = Math.min(loadedCount + BATCH, allImages.length); |
|
|
const frag = document.createDocumentFragment(); |
|
|
|
|
|
for (let i = loadedCount; i < end; i++) { |
|
|
const div = document.createElement('div'); |
|
|
div.className = 'item'; |
|
|
div.dataset.idx = i; |
|
|
div.dataset.src = allImages[i]; |
|
|
div.innerHTML = '<div class="placeholder">#' + (i + 1) + '</div><div class="num">#' + (i + 1) + '</div>'; |
|
|
frag.appendChild(div); |
|
|
} |
|
|
|
|
|
grid.appendChild(frag); |
|
|
loadedCount = end; |
|
|
isLoading = false; |
|
|
|
|
|
loadText.textContent = loadedCount >= allImages.length ? |
|
|
'ALL ' + allImages.length + ' LOADED' : |
|
|
loadedCount + ' / ' + allImages.length; |
|
|
|
|
|
manageImages(); |
|
|
} |
|
|
|
|
|
// Load/unload images based on viewport |
|
|
function manageImages() { |
|
|
const viewTop = window.scrollY - BUFFER; |
|
|
const viewBottom = window.scrollY + window.innerHeight + BUFFER; |
|
|
|
|
|
const items = grid.querySelectorAll('.item'); |
|
|
|
|
|
items.forEach(item => { |
|
|
const rect = item.getBoundingClientRect(); |
|
|
const itemTop = rect.top + window.scrollY; |
|
|
const itemBottom = itemTop + rect.height; |
|
|
|
|
|
const inView = itemBottom > viewTop && itemTop < viewBottom; |
|
|
const hasImg = item.querySelector('img'); |
|
|
|
|
|
if (inView && !hasImg) { |
|
|
// Load image |
|
|
const img = document.createElement('img'); |
|
|
img.referrerPolicy = 'no-referrer'; |
|
|
img.src = item.dataset.src; |
|
|
item.insertBefore(img, item.querySelector('.num')); |
|
|
} else if (!inView && hasImg) { |
|
|
// Unload image to free memory |
|
|
hasImg.remove(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
// Scroll handler |
|
|
let ticking = false; |
|
|
function onScroll() { |
|
|
if (ticking) return; |
|
|
ticking = true; |
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
ticking = false; |
|
|
|
|
|
// Show/hide scroll button |
|
|
scrollBtn.classList.toggle('show', window.scrollY > 400); |
|
|
|
|
|
// Load more if near bottom |
|
|
const trigger = document.getElementById('load-trigger'); |
|
|
if (trigger.getBoundingClientRect().top < window.innerHeight + 500) { |
|
|
loadBatch(); |
|
|
} |
|
|
|
|
|
// Manage which images are loaded |
|
|
manageImages(); |
|
|
}); |
|
|
} |
|
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true }); |
|
|
scrollBtn.onclick = () => window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
|
|
|
|
// Grid click |
|
|
grid.addEventListener('click', e => { |
|
|
const item = e.target.closest('.item'); |
|
|
if (item) { |
|
|
currentIdx = parseInt(item.dataset.idx); |
|
|
openLightbox(); |
|
|
} |
|
|
}); |
|
|
|
|
|
// Lightbox |
|
|
const imgCache = new Set(); |
|
|
|
|
|
function openLightbox() { |
|
|
lightbox.classList.add('open'); |
|
|
document.body.style.overflow = 'hidden'; |
|
|
showImage(); |
|
|
} |
|
|
|
|
|
function closeLightbox() { |
|
|
lightbox.classList.remove('open'); |
|
|
document.body.style.overflow = ''; |
|
|
} |
|
|
|
|
|
function showImage() { |
|
|
const url = allImages[currentIdx]; |
|
|
document.getElementById('lb-counter').textContent = (currentIdx + 1) + ' / ' + allImages.length; |
|
|
|
|
|
if (imgCache.has(url)) { |
|
|
lbLoader.style.display = 'none'; |
|
|
lbImg.src = url; |
|
|
} else { |
|
|
lbLoader.style.display = 'block'; |
|
|
lbImg.style.opacity = '0'; |
|
|
|
|
|
const tmp = new Image(); |
|
|
tmp.onload = () => { |
|
|
imgCache.add(url); |
|
|
lbImg.src = url; |
|
|
lbImg.style.opacity = '1'; |
|
|
lbLoader.style.display = 'none'; |
|
|
preloadNearby(); |
|
|
}; |
|
|
tmp.src = url; |
|
|
} |
|
|
} |
|
|
|
|
|
function preloadNearby() { |
|
|
for (let d = -2; d <= 2; d++) { |
|
|
if (d === 0) continue; |
|
|
const i = currentIdx + d; |
|
|
if (i >= 0 && i < allImages.length) { |
|
|
const url = allImages[i]; |
|
|
if (!imgCache.has(url)) { |
|
|
const img = new Image(); |
|
|
img.onload = () => imgCache.add(url); |
|
|
img.src = url; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function nav(d) { |
|
|
currentIdx = (currentIdx + d + allImages.length) % allImages.length; |
|
|
showImage(); |
|
|
} |
|
|
|
|
|
document.getElementById('lb-close').onclick = closeLightbox; |
|
|
document.getElementById('lb-prev').onclick = () => nav(-1); |
|
|
document.getElementById('lb-next').onclick = () => nav(1); |
|
|
|
|
|
document.getElementById('lb-body').onclick = e => { |
|
|
if (e.target.id === 'lb-body') closeLightbox(); |
|
|
}; |
|
|
|
|
|
document.addEventListener('keydown', e => { |
|
|
if (!lightbox.classList.contains('open')) return; |
|
|
if (e.key === 'Escape') closeLightbox(); |
|
|
if (e.key === 'ArrowLeft') nav(-1); |
|
|
if (e.key === 'ArrowRight') nav(1); |
|
|
}); |
|
|
|
|
|
// Touch swipe |
|
|
let touchX = 0; |
|
|
document.getElementById('lb-body').addEventListener('touchstart', e => { |
|
|
touchX = e.touches[0].clientX; |
|
|
}, { passive: true }); |
|
|
|
|
|
document.getElementById('lb-body').addEventListener('touchend', e => { |
|
|
const diff = touchX - e.changedTouches[0].clientX; |
|
|
if (Math.abs(diff) > 50) nav(diff > 0 ? 1 : -1); |
|
|
}, { passive: true }); |
|
|
|
|
|
// Init |
|
|
fetch('/status') |
|
|
.then(r => r.json()) |
|
|
.then(data => { |
|
|
if (data.status === 'complete' && data.images.length) { |
|
|
allImages = data.images; |
|
|
document.getElementById('total-count').textContent = allImages.length; |
|
|
document.getElementById('loading').style.display = 'none'; |
|
|
document.getElementById('gallery').style.display = 'block'; |
|
|
loadBatch(); |
|
|
} else { |
|
|
document.getElementById('loading-text').textContent = 'NO IMAGES'; |
|
|
document.getElementById('loading-text').classList.add('error'); |
|
|
} |
|
|
}) |
|
|
.catch(() => { |
|
|
document.getElementById('loading-text').textContent = 'ERROR'; |
|
|
document.getElementById('loading-text').classList.add('error'); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html>''' |
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return HTML_PAGE, 200, {'Referrer-Policy': 'no-referrer'} |
|
|
|
|
|
@app.route('/status') |
|
|
def get_status(): |
|
|
return jsonify(state), 200, {'Referrer-Policy': 'no-referrer'} |
|
|
|
|
|
init_images() |
|
|
|
|
|
if __name__ == "__main__": |
|
|
app.run(host="0.0.0.0", port=7860, threaded=True) |