Files
JL Kruger c1a7098ebe Fix iframe recursion and remove all localStorage/cookies site-wide
- Add target="_top" to all ← SPACE / back-to-root links so navigating
  back to index.html breaks out of the content iframe instead of loading
  it recursively inside itself (Images, Music, Watchlists hubs)
- Remove all localStorage usage: visited-star tracking in index.html
  (resets each visit, no persistence without storage), and lightbox
  "show once" guards in all section pages (lightboxes now show on every
  load — Videos, Watchlists, Music). build.py templates updated so
  future generated pages stay clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:03:17 +02:00

540 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Watchlists build script.
For each *-watchlist.md in this folder:
- Parse title, optional first-load lightbox, and playlist entries
- Generate a self-contained HTML page
Then update watchlists-hub.html subtitle with current totals.
Output filenames: strip '-watchlist' suffix, lowercase the stem, no hyphens/spaces.
ContentAddictionArchive-watchlist.md → contentaddictionarchive.html
AnalogFrontier-watchlist.md → analogfrontier.html
etc.
Run after adding or editing any *-watchlist.md file.
"""
import re
from pathlib import Path
from html import escape
HERE = Path(__file__).parent
HUB = HERE / "watchlists-hub.html"
COLORS = ["teal", "green", "toucan"] # rotation order
ALLOW = (
"accelerometer; autoplay; clipboard-write; "
"encrypted-media; gyroscope; picture-in-picture; web-share"
)
# ── MD parsing ────────────────────────────────────────────────────────────────
def slug_from_path(path: Path) -> str:
stem = path.stem # e.g. "AnalogFrontier-watchlist"
stem = re.sub(r'-watchlist$', '', stem, flags=re.IGNORECASE)
return stem.lower().replace('-', '').replace('_', '').replace(' ', '')
def parse_md(path: Path) -> dict:
text = path.read_text(encoding='utf-8')
# Title: first # heading
m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
title = m.group(1).strip() if m else path.stem
# Optional lightbox: >>on-first-load - lightbox - "text"
m = re.search(r'>>on-first-load\s+-\s+lightbox\s+-\s+"([^"]+)"', text)
lightbox = m.group(1).strip() if m else None
# Playlist entries
# - **Name \- YouTube**
# - URL: https://www.youtube.com/playlist?list=PLxxxxxx
playlists = []
for m in re.finditer(
r'-\s+\*\*(.+?)\s*\\-\s*YouTube\*\*\s*\n\s+-\s+URL:\s+(https://[^\s]+)',
text
):
name = m.group(1).strip()
url = m.group(2).strip()
lid = re.search(r'[?&]list=([A-Za-z0-9_-]+)', url)
if lid:
playlists.append({'name': name, 'list_id': lid.group(1)})
return {'title': title, 'lightbox': lightbox, 'playlists': playlists}
# ── HTML generation ───────────────────────────────────────────────────────────
def build_lightbox_html(text: str, slug: str) -> tuple[str, str]:
"""Return (lightbox_div_html, lightbox_js_html). Empty strings if no lightbox."""
if not text:
return '', ''
safe = escape(text)
div = f"""
<div id="lightbox" role="dialog" aria-modal="true" aria-label="Introduction">
<div class="caption-box">
<button class="caption-close" id="caption-close" aria-label="Close">×</button>
<p class="caption-text">{safe}</p>
<p class="dismiss-hint">CLICK ANYWHERE TO DISMISS</p>
</div>
</div>"""
js = f"""
const lightbox = document.getElementById('lightbox');
lightbox.style.display = 'flex';
function closeLightbox() {{ lightbox.style.display = 'none'; }}
lightbox.addEventListener('click', (e) => {{
if (e.target === lightbox) closeLightbox();
}});
document.getElementById('caption-close').addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') closeLightbox();
}});
"""
return div, js
def build_panels_html(playlists: list) -> str:
panels = []
for i, pl in enumerate(playlists):
color = COLORS[i % len(COLORS)]
num = f"PANEL {i+1:02d}"
embed_url = f"https://www.youtube.com/embed/videoseries?list={pl['list_id']}"
title_esc = escape(pl['name'].upper())
playlist_url = f"https://www.youtube.com/playlist?list={pl['list_id']}"
panels.append(f"""
<section class="playlist-panel panel-{color}">
<div class="panel-header">
<h2>{title_esc}</h2>
<span class="panel-number">{num}</span>
</div>
<div class="video-container">
<iframe
data-src="{embed_url}"
allow="{ALLOW}"
allowfullscreen>
</iframe>
</div>
<a href="{playlist_url}" target="_blank" rel="noopener" class="playlist-link">
→ OPEN FULL PLAYLIST ON YOUTUBE
</a>
</section>""")
return '\n'.join(panels)
def build_stagger_css(count: int) -> str:
lines = []
for i in range(1, count + 1):
delay = (i - 1) * 60
lines.append(
f" .playlist-panel:nth-child({i}) {{ animation-delay: {delay}ms; }}"
)
return '\n'.join(lines)
def generate_html(data: dict, slug: str) -> str:
title = data['title']
playlists = data['playlists']
count = len(playlists)
title_upper = title.upper()
count_label = f"{count} PLAYLIST{'S' if count != 1 else ''}"
lightbox_div, lightbox_js = build_lightbox_html(data['lightbox'], slug)
panels_html = build_panels_html(playlists)
stagger_css = build_stagger_css(count)
# Lightbox CSS block (only if there's a lightbox)
lightbox_css = """
/* ── Lightbox ── */
#lightbox {
position: fixed;
inset: 0;
background: rgba(4, 6, 11, 0.92);
display: none;
justify-content: center;
align-items: center;
z-index: 2000;
padding: 2rem;
cursor: pointer;
}
.caption-box {
background: #080d10;
border: 2px solid var(--wl-teal);
border-top: 10px solid var(--wl-toucan);
border-left: 6px solid var(--wl-green);
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
padding: 2rem 2rem 1.5rem;
position: relative;
cursor: default;
}
.caption-close {
position: absolute;
top: 0.6rem;
right: 0.75rem;
background: none;
border: none;
color: var(--wl-teal);
font-family: 'Rambla', sans-serif;
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.3rem;
transition: color 100ms ease;
}
.caption-close:hover { color: var(--wl-toucan); }
.caption-text {
font-style: italic;
font-size: 1.05rem;
color: var(--text-warm);
margin-bottom: 1.25rem;
line-height: 1.65;
padding-right: 1.5rem;
}
.dismiss-hint {
font-weight: 700;
text-transform: uppercase;
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--muted);
text-align: center;
margin-top: 1.25rem;
}
""" if data['lightbox'] else ''
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(title_upper)} | WATCHLISTS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rambla:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<style>
:root {{
--bg-void: #04060b;
--text-warm: #e8d5b8;
--wl-teal: #2ac4b3;
--wl-green: #32dc8c;
--wl-toucan: #ffcf40;
--wl-teal-glow: rgba(42, 196, 179, 0.22);
--wl-green-glow: rgba(50, 220, 140, 0.22);
--wl-toucan-glow: rgba(255, 207, 64, 0.22);
--muted: rgba(232, 213, 184, 0.45);
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; border-radius: 0; }}
body {{
background-color: var(--bg-void);
color: var(--text-warm);
font-family: 'Rambla', sans-serif;
line-height: 1.5;
padding-top: 88px;
}}
/* ── Sticky header ── */
.sticky-header {{
position: fixed;
top: 0; left: 0; right: 0;
height: 72px;
background: var(--bg-void);
border-bottom: 2px solid var(--wl-teal);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
z-index: 1000;
}}
.header-title {{
font-weight: 700;
text-transform: uppercase;
color: var(--wl-toucan);
font-size: 1.1rem;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 55%;
}}
.header-nav {{
display: flex;
align-items: center;
gap: 2rem;
}}
.nav-link {{
font-weight: 700;
text-transform: uppercase;
text-decoration: none;
color: var(--wl-teal);
font-size: 0.85rem;
letter-spacing: 0.04em;
transition: color 100ms ease;
}}
.nav-link:hover {{ color: var(--wl-green); }}
.header-count {{
font-weight: 700;
text-transform: uppercase;
color: var(--wl-green);
font-size: 0.78rem;
letter-spacing: 0.1em;
}}
.menu-toggle {{
display: none;
background: none;
border: 2px solid var(--wl-teal);
color: var(--wl-teal);
padding: 0.4rem 0.6rem;
font-size: 1.1rem;
cursor: pointer;
font-family: inherit;
transition: color 100ms ease, border-color 100ms ease;
}}
.menu-toggle:hover {{ color: var(--wl-green); border-color: var(--wl-green); }}
@media (max-width: 600px) {{
.header-nav {{
display: none;
flex-direction: column;
align-items: flex-start;
position: absolute;
top: 72px; left: 0; right: 0;
background: var(--bg-void);
padding: 1.25rem 2rem;
border-bottom: 2px solid var(--wl-teal);
gap: 0.75rem;
}}
.header-nav.open {{ display: flex; }}
.menu-toggle {{ display: block; }}
.header-title {{ max-width: 65%; }}
}}
{lightbox_css}
/* ── Panels ── */
.container {{
max-width: 900px;
margin: 0 auto;
padding: 2rem 2rem 4rem;
}}
@media (max-width: 600px) {{
.container {{ padding: 1.5rem 1.25rem 3rem; }}
}}
@keyframes panelIn {{
from {{ opacity: 0; transform: translateY(20px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.playlist-panel {{
margin-bottom: 3.5rem;
border: 2px solid var(--border-color);
background: var(--bg-void);
position: relative;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
animation: panelIn 350ms ease both;
}}
{stagger_css}
.panel-header {{
background: var(--border-color);
padding: 0.75rem 1.25rem;
color: var(--bg-void);
display: flex;
justify-content: space-between;
align-items: center;
}}
.panel-header h2 {{
font-size: 1.15rem;
font-weight: 700;
text-transform: uppercase;
margin: 0;
line-height: 1.2;
}}
.panel-number {{
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
color: rgba(4, 6, 11, 0.75);
letter-spacing: 0.06em;
white-space: nowrap;
margin-left: 1rem;
flex-shrink: 0;
}}
.video-container {{
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
}}
.video-container iframe {{
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
border: 0;
}}
.panel-teal {{ --border-color: var(--wl-teal); }}
.panel-green {{ --border-color: var(--wl-green); }}
.panel-toucan {{ --border-color: var(--wl-toucan); }}
.playlist-link {{
display: block;
padding: 0.85rem 1.25rem;
font-weight: 700;
text-transform: uppercase;
font-size: 0.9rem;
letter-spacing: 0.08em;
color: var(--border-color);
text-decoration: none;
border-top: 1px solid var(--border-color);
transition: background 100ms ease, color 100ms ease;
}}
.playlist-link:hover {{
background: var(--border-color);
color: var(--bg-void);
}}
</style>
</head>
<body>
<header class="sticky-header">
<div class="header-title">{escape(title_upper)}</div>
<button class="menu-toggle" id="menu-toggle">☰</button>
<nav class="header-nav" id="header-nav">
<a href="watchlists-hub.html" class="nav-link">← WATCHLISTS</a>
<span class="header-count">{count_label}</span>
</nav>
</header>
{lightbox_div}
<main class="container">
{panels_html}
</main>
<script>
document.addEventListener('DOMContentLoaded', () => {{
{lightbox_js}
// Mobile nav toggle
const menuToggle = document.getElementById('menu-toggle');
const headerNav = document.getElementById('header-nav');
menuToggle.addEventListener('click', (e) => {{
e.stopPropagation();
headerNav.classList.toggle('open');
}});
document.addEventListener('click', (e) => {{
if (!headerNav.contains(e.target) && e.target !== menuToggle) {{
headerNav.classList.remove('open');
}}
}});
// Lazy-load iframes via IntersectionObserver
const observer = new IntersectionObserver((entries, obs) => {{
entries.forEach(entry => {{
if (entry.isIntersecting) {{
const iframe = entry.target;
const src = iframe.getAttribute('data-src');
if (src) {{
iframe.setAttribute('src', src);
iframe.removeAttribute('data-src');
}}
obs.unobserve(iframe);
}}
}});
}}, {{ rootMargin: '200px 0px' }});
document.querySelectorAll('iframe[data-src]').forEach(
iframe => observer.observe(iframe)
);
}});
</script>
</body>
</html>
"""
# ── Hub subtitle update ───────────────────────────────────────────────────────
def update_hub_subtitle(collection_count: int, playlist_total: int):
hub_text = HUB.read_text(encoding='utf-8')
subtitle = (
f'<div class="subtitle">'
f'{collection_count} COLLECTION{"S" if collection_count != 1 else ""}'
f' · CURATED VIDEO ARCHIVES'
f' · {playlist_total} PLAYLIST{"S" if playlist_total != 1 else ""}'
f'</div>'
)
hub_text = re.sub(
r'<!-- SUBTITLE-START -->.*?<!-- SUBTITLE-END -->',
f'<!-- SUBTITLE-START -->{subtitle}<!-- SUBTITLE-END -->',
hub_text,
flags=re.DOTALL
)
HUB.write_text(hub_text, encoding='utf-8')
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
md_files = sorted(HERE.glob('*-watchlist.md'))
if not md_files:
print("No *-watchlist.md files found.")
return
total_playlists = 0
for md_path in md_files:
slug = slug_from_path(md_path)
out = HERE / f"{slug}.html"
data = parse_md(md_path)
count = len(data['playlists'])
if count == 0:
print(f" SKIP {md_path.name} — no playlists found")
continue
html = generate_html(data, slug)
out.write_text(html, encoding='utf-8')
total_playlists += count
print(f" BUILD {out.name}{data['title']} ({count} playlists)")
update_hub_subtitle(len(md_files), total_playlists)
print(f"\nUpdated hub subtitle — {len(md_files)} collections, {total_playlists} playlists total")
if __name__ == '__main__':
main()