jerrycans commited on
Commit
cf78d72
·
verified ·
1 Parent(s): b34d3b4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +193 -215
app.py CHANGED
@@ -3,14 +3,9 @@ import os
3
 
4
  app = Flask(__name__)
5
 
6
- # Configuration
7
  LINKS_FILE = "links.txt"
8
 
9
- # Global state
10
- state = {
11
- "status": "idle",
12
- "images": []
13
- }
14
 
15
  def load_links():
16
  links = []
@@ -25,10 +20,7 @@ def load_links():
25
  def init_images():
26
  global state
27
  links = load_links()
28
- if links:
29
- state = {"status": "complete", "images": links}
30
- else:
31
- state = {"status": "error", "images": []}
32
 
33
  HTML_PAGE = '''<!DOCTYPE html>
34
  <html lang="en">
@@ -86,117 +78,80 @@ HTML_PAGE = '''<!DOCTYPE html>
86
  font-size: 11px;
87
  font-weight: 700;
88
  letter-spacing: 2px;
89
- animation: flash 2s infinite;
 
90
  }
91
 
92
- @keyframes flash {
93
- 0%, 100% { opacity: 1; }
94
- 50% { opacity: 0.7; }
 
 
 
 
 
 
95
  }
96
 
97
- /* ===== LOADING SCREEN ===== */
98
- #loading-screen {
 
 
 
 
99
  display: flex;
100
- flex-direction: column;
101
  align-items: center;
102
  justify-content: center;
103
- min-height: calc(100vh - 120px);
104
- padding: 40px 20px;
105
- }
106
-
107
- .epstein-loader {
108
- position: relative;
109
- width: 120px;
110
- height: 120px;
111
- margin-bottom: 30px;
112
  }
113
 
114
- .epstein-image {
115
- width: 100%;
116
- height: 100%;
117
- object-fit: cover;
118
- filter: grayscale(100%) contrast(1.2);
119
- border: 3px solid #fff;
120
- animation: imagePulse 2s ease-in-out infinite;
121
  }
122
 
123
- @keyframes imagePulse {
124
- 0%, 100% {
125
- filter: grayscale(100%) contrast(1.2) brightness(1);
126
- transform: scale(1);
127
- }
128
- 50% {
129
- filter: grayscale(100%) contrast(1.5) brightness(1.2);
130
- transform: scale(1.02);
131
- }
132
  }
133
 
134
- .corner-bracket {
135
- position: absolute;
136
- width: 15px;
137
- height: 15px;
138
- border: 2px solid #fff;
139
  }
140
 
141
- .corner-bracket.tl { top: -6px; left: -6px; border-right: none; border-bottom: none; }
142
- .corner-bracket.tr { top: -6px; right: -6px; border-left: none; border-bottom: none; }
143
- .corner-bracket.bl { bottom: -6px; left: -6px; border-right: none; border-top: none; }
144
- .corner-bracket.br { bottom: -6px; right: -6px; border-left: none; border-top: none; }
145
-
146
- .scan-line {
147
- position: absolute;
148
- top: 0;
149
- left: 0;
150
- width: 100%;
151
- height: 3px;
152
- background: #fff;
153
- animation: scan 1.5s linear infinite;
154
- box-shadow: 0 0 10px #fff;
155
  }
156
 
157
- @keyframes scan {
158
- 0% { top: 0; opacity: 1; }
159
- 100% { top: 100%; opacity: 0.3; }
 
 
 
 
 
160
  }
161
 
162
- .rotating-ring {
163
- position: absolute;
164
- top: -15px;
165
- left: -15px;
166
- right: -15px;
167
- bottom: -15px;
168
- border: 2px dashed rgba(255,255,255,0.3);
169
- animation: rotate 8s linear infinite;
170
  }
171
 
172
- @keyframes rotate {
173
- from { transform: rotate(0deg); }
174
  to { transform: rotate(360deg); }
175
  }
176
 
177
  .loading-text {
178
- font-size: 14px;
179
  letter-spacing: 4px;
180
- color: #fff;
181
- animation: textPulse 1.5s ease-in-out infinite;
182
- }
183
-
184
- @keyframes textPulse {
185
- 0%, 100% { opacity: 1; }
186
- 50% { opacity: 0.5; }
187
- }
188
-
189
- .loading-dots::after {
190
- content: '';
191
- animation: dots 1.5s steps(4, end) infinite;
192
- }
193
-
194
- @keyframes dots {
195
- 0% { content: ''; }
196
- 25% { content: '.'; }
197
- 50% { content: '..'; }
198
- 75% { content: '...'; }
199
- 100% { content: ''; }
200
  }
201
 
202
  .error-message {
@@ -240,15 +195,10 @@ HTML_PAGE = '''<!DOCTYPE html>
240
  overflow: hidden;
241
  border: 2px solid #333;
242
  cursor: pointer;
243
- transition: all 0.2s ease;
244
  background: #111;
245
  position: relative;
246
  }
247
 
248
- .gallery-item:hover {
249
- border-color: #fff;
250
- }
251
-
252
  .gallery-item:active {
253
  transform: scale(0.98);
254
  }
@@ -258,7 +208,7 @@ HTML_PAGE = '''<!DOCTYPE html>
258
  height: 100%;
259
  object-fit: cover;
260
  opacity: 0;
261
- transition: opacity 0.3s;
262
  }
263
 
264
  .gallery-item img.loaded {
@@ -290,25 +240,34 @@ HTML_PAGE = '''<!DOCTYPE html>
290
  text-align: center;
291
  }
292
 
293
- /* Load more */
294
  #load-more-trigger {
295
- height: 100px;
296
  display: flex;
297
  align-items: center;
298
  justify-content: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  }
300
 
301
  .load-more-text {
302
  color: #444;
303
- font-size: 11px;
304
  letter-spacing: 2px;
305
  }
306
 
307
- .load-more-text.loading {
308
- color: #fff;
309
- animation: textPulse 1s infinite;
310
- }
311
-
312
  #scroll-top {
313
  position: fixed;
314
  bottom: 20px;
@@ -324,7 +283,6 @@ HTML_PAGE = '''<!DOCTYPE html>
324
  display: none;
325
  align-items: center;
326
  justify-content: center;
327
- transition: all 0.2s;
328
  }
329
 
330
  #scroll-top:hover {
@@ -381,7 +339,6 @@ HTML_PAGE = '''<!DOCTYPE html>
381
  padding: 8px 15px;
382
  font-family: inherit;
383
  font-weight: 700;
384
- transition: all 0.15s;
385
  -webkit-tap-highlight-color: transparent;
386
  }
387
 
@@ -414,13 +371,26 @@ HTML_PAGE = '''<!DOCTYPE html>
414
  max-height: 100%;
415
  object-fit: contain;
416
  border: 3px solid #fff;
 
 
417
  }
418
 
419
- .lb-loading {
 
 
 
 
420
  position: absolute;
421
- color: #666;
422
- font-size: 12px;
423
- letter-spacing: 2px;
 
 
 
 
 
 
 
424
  }
425
 
426
  .lb-footer {
@@ -446,20 +416,19 @@ HTML_PAGE = '''<!DOCTYPE html>
446
  }
447
 
448
  .info-banner {
449
- font-size: 10px;
450
  padding: 8px 10px;
451
  }
452
 
 
 
 
 
453
  .gallery-grid {
454
  grid-template-columns: repeat(3, 1fr);
455
  gap: 6px;
456
  }
457
 
458
- .gallery-header {
459
- margin-bottom: 15px;
460
- padding-bottom: 12px;
461
- }
462
-
463
  .gallery-count {
464
  font-size: 22px;
465
  }
@@ -496,11 +465,6 @@ HTML_PAGE = '''<!DOCTYPE html>
496
  border-width: 2px;
497
  }
498
 
499
- .epstein-loader {
500
- width: 100px;
501
- height: 100px;
502
- }
503
-
504
  #scroll-top {
505
  width: 40px;
506
  height: 40px;
@@ -519,20 +483,20 @@ HTML_PAGE = '''<!DOCTYPE html>
519
  </head>
520
  <body>
521
  <div id="header"><span>EPSTEIN FILES</span></div>
522
- <div class="info-banner">★ HERE ARE ALL THE IMAGES ★</div>
 
 
 
 
 
 
 
 
523
 
524
  <!-- Loading Screen -->
525
  <div id="loading-screen">
526
- <div class="epstein-loader">
527
- <div class="rotating-ring"></div>
528
- <div class="corner-bracket tl"></div>
529
- <div class="corner-bracket tr"></div>
530
- <div class="corner-bracket bl"></div>
531
- <div class="corner-bracket br"></div>
532
- <img class="epstein-image" src="https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Epstein-mugshot.jpg/440px-Epstein-mugshot.jpg" alt="Loading">
533
- <div class="scan-line"></div>
534
- </div>
535
- <div class="loading-text" id="loading-text">LOADING<span class="loading-dots"></span></div>
536
  </div>
537
 
538
  <!-- Gallery Screen -->
@@ -542,6 +506,7 @@ HTML_PAGE = '''<!DOCTYPE html>
542
  </div>
543
  <div class="gallery-grid" id="gallery-grid"></div>
544
  <div id="load-more-trigger">
 
545
  <div class="load-more-text" id="load-more-text">SCROLL FOR MORE</div>
546
  </div>
547
  </div>
@@ -559,7 +524,7 @@ HTML_PAGE = '''<!DOCTYPE html>
559
  </div>
560
  </div>
561
  <div class="lb-image-container">
562
- <div class="lb-loading" id="lb-loading">LOADING...</div>
563
  <img id="lightbox-img" src="" alt="">
564
  </div>
565
  <div class="lb-footer">
@@ -579,34 +544,81 @@ HTML_PAGE = '''<!DOCTYPE html>
579
  const loadingText = document.getElementById('loading-text');
580
  const grid = document.getElementById('gallery-grid');
581
  const loadMoreText = document.getElementById('load-more-text');
 
582
  const scrollTopBtn = document.getElementById('scroll-top');
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  function loadMoreImages() {
585
  if (isLoading || loadedCount >= allImages.length) return;
586
 
587
  isLoading = true;
 
588
  loadMoreText.textContent = 'LOADING...';
589
- loadMoreText.classList.add('loading');
590
 
591
- const start = loadedCount;
592
- const end = Math.min(loadedCount + BATCH_SIZE, allImages.length);
593
-
594
- for (let i = start; i < end; i++) {
595
- createImageItem(allImages[i], i);
596
- }
597
-
598
- loadedCount = end;
599
-
600
- setTimeout(() => {
 
 
 
 
 
 
 
 
601
  isLoading = false;
 
 
602
  if (loadedCount >= allImages.length) {
603
- loadMoreText.textContent = 'ALL ' + allImages.length + ' FILES LOADED';
604
- loadMoreText.classList.remove('loading');
605
  } else {
606
- loadMoreText.textContent = 'SCROLL FOR MORE (' + loadedCount + '/' + allImages.length + ')';
607
- loadMoreText.classList.remove('loading');
608
  }
609
- }, 100);
610
  }
611
 
612
  function createImageItem(url, idx) {
@@ -617,58 +629,23 @@ HTML_PAGE = '''<!DOCTYPE html>
617
  placeholder.className = 'placeholder';
618
  placeholder.textContent = '#' + (idx + 1);
619
 
620
- const imgEl = document.createElement('img');
621
- imgEl.alt = 'File ' + (idx + 1);
622
- imgEl.onload = () => {
623
- imgEl.classList.add('loaded');
624
- };
625
- imgEl.onerror = () => {
626
- placeholder.textContent = 'ERROR';
627
- };
628
-
629
- // Lazy load with intersection observer
630
- imgEl.dataset.src = url;
631
- imgEl.dataset.idx = idx;
632
 
633
  const number = document.createElement('div');
634
  number.className = 'item-number';
635
  number.textContent = '#' + (idx + 1);
636
 
637
  item.appendChild(placeholder);
638
- item.appendChild(imgEl);
639
  item.appendChild(number);
640
  item.onclick = () => openLightbox(idx);
641
- grid.appendChild(item);
642
 
643
- imageObserver.observe(imgEl);
644
  }
645
 
646
- // Intersection observer for lazy loading images
647
- const imageObserver = new IntersectionObserver((entries) => {
648
- entries.forEach(entry => {
649
- if (entry.isIntersecting) {
650
- const img = entry.target;
651
- if (img.dataset.src && !img.src) {
652
- img.src = img.dataset.src;
653
- }
654
- imageObserver.unobserve(img);
655
- }
656
- });
657
- }, {
658
- rootMargin: '200px'
659
- });
660
-
661
- // Intersection observer for infinite scroll
662
- const scrollObserver = new IntersectionObserver((entries) => {
663
- entries.forEach(entry => {
664
- if (entry.isIntersecting) {
665
- loadMoreImages();
666
- }
667
- });
668
- }, {
669
- rootMargin: '400px'
670
- });
671
-
672
  function showGallery(imageList) {
673
  allImages = imageList;
674
  loadedCount = 0;
@@ -686,16 +663,20 @@ HTML_PAGE = '''<!DOCTYPE html>
686
  }
687
 
688
  function showError(msg) {
689
- loadingText.innerHTML = msg;
690
  loadingText.classList.add('error-message');
 
691
  }
692
 
693
- // Scroll to top button
 
694
  window.addEventListener('scroll', () => {
695
- if (window.scrollY > 500) {
696
- scrollTopBtn.classList.add('visible');
697
- } else {
698
- scrollTopBtn.classList.remove('visible');
 
 
699
  }
700
  });
701
 
@@ -708,7 +689,7 @@ HTML_PAGE = '''<!DOCTYPE html>
708
  const lbImg = document.getElementById('lightbox-img');
709
  const lbCounter = document.getElementById('lb-counter');
710
  const lbFilename = document.getElementById('lb-filename');
711
- const lbLoading = document.getElementById('lb-loading');
712
 
713
  function openLightbox(idx) {
714
  currentIdx = idx;
@@ -720,15 +701,17 @@ HTML_PAGE = '''<!DOCTYPE html>
720
  function closeLightbox() {
721
  lightbox.classList.remove('active');
722
  document.body.style.overflow = '';
 
 
723
  }
724
 
725
  function updateLightbox() {
726
- lbLoading.style.display = 'block';
727
- lbImg.style.opacity = '0';
728
 
729
  lbImg.onload = () => {
730
- lbLoading.style.display = 'none';
731
- lbImg.style.opacity = '1';
732
  };
733
 
734
  lbImg.src = allImages[currentIdx];
@@ -755,25 +738,20 @@ HTML_PAGE = '''<!DOCTYPE html>
755
 
756
  // Touch swipe
757
  let touchStartX = 0;
758
- let touchStartY = 0;
759
-
760
  const lbImageContainer = document.querySelector('.lb-image-container');
761
 
762
  lbImageContainer.addEventListener('touchstart', e => {
763
  touchStartX = e.touches[0].clientX;
764
- touchStartY = e.touches[0].clientY;
765
  }, { passive: true });
766
 
767
  lbImageContainer.addEventListener('touchend', e => {
768
- const diffX = touchStartX - e.changedTouches[0].clientX;
769
- const diffY = touchStartY - e.changedTouches[0].clientY;
770
-
771
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
772
- navigate(diffX > 0 ? 1 : -1);
773
  }
774
  }, { passive: true });
775
 
776
- // Load
777
  fetch('/status')
778
  .then(r => r.json())
779
  .then(data => {
 
3
 
4
  app = Flask(__name__)
5
 
 
6
  LINKS_FILE = "links.txt"
7
 
8
+ state = {"status": "idle", "images": []}
 
 
 
 
9
 
10
  def load_links():
11
  links = []
 
20
  def init_images():
21
  global state
22
  links = load_links()
23
+ state = {"status": "complete" if links else "error", "images": links}
 
 
 
24
 
25
  HTML_PAGE = '''<!DOCTYPE html>
26
  <html lang="en">
 
78
  font-size: 11px;
79
  font-weight: 700;
80
  letter-spacing: 2px;
81
+ position: relative;
82
+ overflow: hidden;
83
  }
84
 
85
+ .info-banner::before {
86
+ content: '';
87
+ position: absolute;
88
+ top: 0;
89
+ left: -100%;
90
+ width: 100%;
91
+ height: 100%;
92
+ background: linear-gradient(90deg, transparent, rgba(0,0,0,0.1), transparent);
93
+ animation: shine 2s infinite;
94
  }
95
 
96
+ @keyframes shine {
97
+ 0% { left: -100%; }
98
+ 100% { left: 100%; }
99
+ }
100
+
101
+ .banner-lines {
102
  display: flex;
 
103
  align-items: center;
104
  justify-content: center;
105
+ gap: 15px;
 
 
 
 
 
 
 
 
106
  }
107
 
108
+ .banner-line {
109
+ width: 50px;
110
+ height: 2px;
111
+ background: #000;
 
 
 
112
  }
113
 
114
+ .banner-line.animated {
115
+ animation: lineGrow 1.5s ease-in-out infinite;
 
 
 
 
 
 
 
116
  }
117
 
118
+ .banner-line.animated.delay {
119
+ animation-delay: 0.3s;
 
 
 
120
  }
121
 
122
+ @keyframes lineGrow {
123
+ 0%, 100% { width: 30px; opacity: 0.5; }
124
+ 50% { width: 60px; opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
 
127
+ /* ===== LOADING SCREEN ===== */
128
+ #loading-screen {
129
+ display: flex;
130
+ flex-direction: column;
131
+ align-items: center;
132
+ justify-content: center;
133
+ min-height: calc(100vh - 120px);
134
+ padding: 40px 20px;
135
  }
136
 
137
+ .spinner {
138
+ width: 60px;
139
+ height: 60px;
140
+ border: 3px solid #333;
141
+ border-top-color: #fff;
142
+ border-radius: 50%;
143
+ animation: spin 0.8s linear infinite;
144
+ margin-bottom: 25px;
145
  }
146
 
147
+ @keyframes spin {
 
148
  to { transform: rotate(360deg); }
149
  }
150
 
151
  .loading-text {
152
+ font-size: 12px;
153
  letter-spacing: 4px;
154
+ color: #666;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  }
156
 
157
  .error-message {
 
195
  overflow: hidden;
196
  border: 2px solid #333;
197
  cursor: pointer;
 
198
  background: #111;
199
  position: relative;
200
  }
201
 
 
 
 
 
202
  .gallery-item:active {
203
  transform: scale(0.98);
204
  }
 
208
  height: 100%;
209
  object-fit: cover;
210
  opacity: 0;
211
+ transition: opacity 0.2s;
212
  }
213
 
214
  .gallery-item img.loaded {
 
240
  text-align: center;
241
  }
242
 
 
243
  #load-more-trigger {
244
+ height: 80px;
245
  display: flex;
246
  align-items: center;
247
  justify-content: center;
248
+ gap: 15px;
249
+ }
250
+
251
+ .load-spinner {
252
+ width: 20px;
253
+ height: 20px;
254
+ border: 2px solid #333;
255
+ border-top-color: #fff;
256
+ border-radius: 50%;
257
+ animation: spin 0.8s linear infinite;
258
+ display: none;
259
+ }
260
+
261
+ .load-spinner.active {
262
+ display: block;
263
  }
264
 
265
  .load-more-text {
266
  color: #444;
267
+ font-size: 10px;
268
  letter-spacing: 2px;
269
  }
270
 
 
 
 
 
 
271
  #scroll-top {
272
  position: fixed;
273
  bottom: 20px;
 
283
  display: none;
284
  align-items: center;
285
  justify-content: center;
 
286
  }
287
 
288
  #scroll-top:hover {
 
339
  padding: 8px 15px;
340
  font-family: inherit;
341
  font-weight: 700;
 
342
  -webkit-tap-highlight-color: transparent;
343
  }
344
 
 
371
  max-height: 100%;
372
  object-fit: contain;
373
  border: 3px solid #fff;
374
+ opacity: 0;
375
+ transition: opacity 0.2s;
376
  }
377
 
378
+ #lightbox-img.loaded {
379
+ opacity: 1;
380
+ }
381
+
382
+ .lb-spinner {
383
  position: absolute;
384
+ width: 40px;
385
+ height: 40px;
386
+ border: 3px solid #333;
387
+ border-top-color: #fff;
388
+ border-radius: 50%;
389
+ animation: spin 0.8s linear infinite;
390
+ }
391
+
392
+ .lb-spinner.hidden {
393
+ display: none;
394
  }
395
 
396
  .lb-footer {
 
416
  }
417
 
418
  .info-banner {
419
+ font-size: 9px;
420
  padding: 8px 10px;
421
  }
422
 
423
+ .banner-line {
424
+ width: 30px;
425
+ }
426
+
427
  .gallery-grid {
428
  grid-template-columns: repeat(3, 1fr);
429
  gap: 6px;
430
  }
431
 
 
 
 
 
 
432
  .gallery-count {
433
  font-size: 22px;
434
  }
 
465
  border-width: 2px;
466
  }
467
 
 
 
 
 
 
468
  #scroll-top {
469
  width: 40px;
470
  height: 40px;
 
483
  </head>
484
  <body>
485
  <div id="header"><span>EPSTEIN FILES</span></div>
486
+ <div class="info-banner">
487
+ <div class="banner-lines">
488
+ <div class="banner-line animated"></div>
489
+ <div class="banner-line animated delay"></div>
490
+ ★ HERE ARE ALL THE IMAGES ★
491
+ <div class="banner-line animated delay"></div>
492
+ <div class="banner-line animated"></div>
493
+ </div>
494
+ </div>
495
 
496
  <!-- Loading Screen -->
497
  <div id="loading-screen">
498
+ <div class="spinner"></div>
499
+ <div class="loading-text" id="loading-text">LOADING</div>
 
 
 
 
 
 
 
 
500
  </div>
501
 
502
  <!-- Gallery Screen -->
 
506
  </div>
507
  <div class="gallery-grid" id="gallery-grid"></div>
508
  <div id="load-more-trigger">
509
+ <div class="load-spinner" id="load-spinner"></div>
510
  <div class="load-more-text" id="load-more-text">SCROLL FOR MORE</div>
511
  </div>
512
  </div>
 
524
  </div>
525
  </div>
526
  <div class="lb-image-container">
527
+ <div class="lb-spinner" id="lb-spinner"></div>
528
  <img id="lightbox-img" src="" alt="">
529
  </div>
530
  <div class="lb-footer">
 
544
  const loadingText = document.getElementById('loading-text');
545
  const grid = document.getElementById('gallery-grid');
546
  const loadMoreText = document.getElementById('load-more-text');
547
+ const loadSpinner = document.getElementById('load-spinner');
548
  const scrollTopBtn = document.getElementById('scroll-top');
549
 
550
+ // Only load images that are visible - unload ones that scroll away
551
+ const imageObserver = new IntersectionObserver((entries) => {
552
+ entries.forEach(entry => {
553
+ const img = entry.target;
554
+ if (entry.isIntersecting) {
555
+ // Load image
556
+ if (img.dataset.src && !img.src) {
557
+ img.src = img.dataset.src;
558
+ img.onload = () => img.classList.add('loaded');
559
+ img.onerror = () => {
560
+ img.parentElement.querySelector('.placeholder').textContent = 'ERR';
561
+ };
562
+ }
563
+ } else {
564
+ // Unload image if it's far from viewport to save memory
565
+ if (img.src && !isNearViewport(img)) {
566
+ img.src = '';
567
+ img.classList.remove('loaded');
568
+ }
569
+ }
570
+ });
571
+ }, {
572
+ rootMargin: '300px'
573
+ });
574
+
575
+ function isNearViewport(el) {
576
+ const rect = el.getBoundingClientRect();
577
+ const buffer = window.innerHeight * 2;
578
+ return rect.top < window.innerHeight + buffer && rect.bottom > -buffer;
579
+ }
580
+
581
+ // Scroll observer for infinite loading
582
+ const scrollObserver = new IntersectionObserver((entries) => {
583
+ if (entries[0].isIntersecting) {
584
+ loadMoreImages();
585
+ }
586
+ }, { rootMargin: '500px' });
587
+
588
  function loadMoreImages() {
589
  if (isLoading || loadedCount >= allImages.length) return;
590
 
591
  isLoading = true;
592
+ loadSpinner.classList.add('active');
593
  loadMoreText.textContent = 'LOADING...';
 
594
 
595
+ // Use requestAnimationFrame for smooth rendering
596
+ requestAnimationFrame(() => {
597
+ const fragment = document.createDocumentFragment();
598
+ const end = Math.min(loadedCount + BATCH_SIZE, allImages.length);
599
+
600
+ for (let i = loadedCount; i < end; i++) {
601
+ fragment.appendChild(createImageItem(allImages[i], i));
602
+ }
603
+
604
+ grid.appendChild(fragment);
605
+ loadedCount = end;
606
+
607
+ // Observe new images
608
+ grid.querySelectorAll('img:not([data-observed])').forEach(img => {
609
+ img.dataset.observed = 'true';
610
+ imageObserver.observe(img);
611
+ });
612
+
613
  isLoading = false;
614
+ loadSpinner.classList.remove('active');
615
+
616
  if (loadedCount >= allImages.length) {
617
+ loadMoreText.textContent = 'ALL ' + allImages.length + ' LOADED';
 
618
  } else {
619
+ loadMoreText.textContent = loadedCount + ' / ' + allImages.length;
 
620
  }
621
+ });
622
  }
623
 
624
  function createImageItem(url, idx) {
 
629
  placeholder.className = 'placeholder';
630
  placeholder.textContent = '#' + (idx + 1);
631
 
632
+ const img = document.createElement('img');
633
+ img.dataset.src = url;
634
+ img.dataset.idx = idx;
635
+ img.alt = '';
 
 
 
 
 
 
 
 
636
 
637
  const number = document.createElement('div');
638
  number.className = 'item-number';
639
  number.textContent = '#' + (idx + 1);
640
 
641
  item.appendChild(placeholder);
642
+ item.appendChild(img);
643
  item.appendChild(number);
644
  item.onclick = () => openLightbox(idx);
 
645
 
646
+ return item;
647
  }
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  function showGallery(imageList) {
650
  allImages = imageList;
651
  loadedCount = 0;
 
663
  }
664
 
665
  function showError(msg) {
666
+ loadingText.textContent = msg;
667
  loadingText.classList.add('error-message');
668
+ document.querySelector('.spinner').style.display = 'none';
669
  }
670
 
671
+ // Scroll to top
672
+ let scrollTicking = false;
673
  window.addEventListener('scroll', () => {
674
+ if (!scrollTicking) {
675
+ requestAnimationFrame(() => {
676
+ scrollTopBtn.classList.toggle('visible', window.scrollY > 500);
677
+ scrollTicking = false;
678
+ });
679
+ scrollTicking = true;
680
  }
681
  });
682
 
 
689
  const lbImg = document.getElementById('lightbox-img');
690
  const lbCounter = document.getElementById('lb-counter');
691
  const lbFilename = document.getElementById('lb-filename');
692
+ const lbSpinner = document.getElementById('lb-spinner');
693
 
694
  function openLightbox(idx) {
695
  currentIdx = idx;
 
701
  function closeLightbox() {
702
  lightbox.classList.remove('active');
703
  document.body.style.overflow = '';
704
+ lbImg.src = '';
705
+ lbImg.classList.remove('loaded');
706
  }
707
 
708
  function updateLightbox() {
709
+ lbSpinner.classList.remove('hidden');
710
+ lbImg.classList.remove('loaded');
711
 
712
  lbImg.onload = () => {
713
+ lbSpinner.classList.add('hidden');
714
+ lbImg.classList.add('loaded');
715
  };
716
 
717
  lbImg.src = allImages[currentIdx];
 
738
 
739
  // Touch swipe
740
  let touchStartX = 0;
 
 
741
  const lbImageContainer = document.querySelector('.lb-image-container');
742
 
743
  lbImageContainer.addEventListener('touchstart', e => {
744
  touchStartX = e.touches[0].clientX;
 
745
  }, { passive: true });
746
 
747
  lbImageContainer.addEventListener('touchend', e => {
748
+ const diff = touchStartX - e.changedTouches[0].clientX;
749
+ if (Math.abs(diff) > 50) {
750
+ navigate(diff > 0 ? 1 : -1);
 
 
751
  }
752
  }, { passive: true });
753
 
754
+ // Init
755
  fetch('/status')
756
  .then(r => r.json())
757
  .then(data => {