Add Music section: hub, template, build pipeline, and homepage wire
Music/build.py parses *-music.md files and generates collection pages supporting YouTube video embeds (lazy IntersectionObserver) and FileBrowser audio cards with floating media player (prev/next/auto-advance). Music star node wired in index.html. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
769
Music/build.py
Normal file
769
Music/build.py
Normal file
@@ -0,0 +1,769 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Music build script.
|
||||
|
||||
For each *-music.md in this folder:
|
||||
- Parse title, optional first-load lightbox, and track entries
|
||||
- Two entry types detected by URL:
|
||||
YouTube → youtube.com/watch?v=VIDEO_ID → video embed card
|
||||
Audio → files.exopraxist.org/share/TOKEN → audio card + floating player
|
||||
- Generate a self-contained HTML page
|
||||
|
||||
Then update music-hub.html subtitle with current totals.
|
||||
|
||||
Output filenames: strip '-music' suffix, lowercase the stem.
|
||||
OldMusicandMVs-music.md → oldmusicandmvs.html
|
||||
|
||||
Run after adding or editing any *-music.md file.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from html import escape
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
HUB = HERE / "music-hub.html"
|
||||
|
||||
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
|
||||
stem = re.sub(r'-music$', '', stem, flags=re.IGNORECASE)
|
||||
return stem.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
|
||||
|
||||
def parse_md(path: Path) -> dict:
|
||||
text = path.read_text(encoding='utf-8')
|
||||
|
||||
m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
|
||||
title = m.group(1).strip() if m else path.stem
|
||||
|
||||
m = re.search(r'>>on-first-load\s+-\s+lightbox\s+-\s+"([^"]+)"', text)
|
||||
lightbox = m.group(1).strip() if m else None
|
||||
|
||||
entries = []
|
||||
for m in re.finditer(
|
||||
r'-\s+\*\*(.+?)\*\*\s*\n\s+-\s+URL:\s+(https://[^\s]+)',
|
||||
text
|
||||
):
|
||||
name = m.group(1).strip()
|
||||
# Strip trailing \- Source label from name (e.g. "Title \- YouTube")
|
||||
name = re.sub(r'\s*\\-\s*.+$', '', name).strip()
|
||||
url = m.group(2).strip()
|
||||
|
||||
if 'youtube.com/watch' in url:
|
||||
vid = re.search(r'[?&]v=([A-Za-z0-9_-]+)', url)
|
||||
if vid:
|
||||
entries.append({'type': 'youtube', 'name': name,
|
||||
'video_id': vid.group(1)})
|
||||
elif 'files.exopraxist.org/share/' in url:
|
||||
token = url.rstrip('/').split('/')[-1]
|
||||
entries.append({'type': 'audio', 'name': name,
|
||||
'share_url': url,
|
||||
'direct_url': f"https://files.exopraxist.org/api/public/dl/{token}"})
|
||||
|
||||
return {'title': title, 'lightbox': lightbox, 'entries': entries}
|
||||
|
||||
|
||||
# ── HTML blocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
def build_lightbox_html(text: str, slug: str) -> tuple[str, str]:
|
||||
if not text:
|
||||
return '', ''
|
||||
|
||||
# Preserve — JL attribution if present at the end
|
||||
body = text
|
||||
attribution = ''
|
||||
m = re.search(r'\s*-\s*JL\s*$', text)
|
||||
if m:
|
||||
body = text[:m.start()].strip()
|
||||
attribution = '<p class="lightbox-attribution">— JL</p>'
|
||||
|
||||
safe_body = escape(body)
|
||||
|
||||
div = f"""
|
||||
<div id="lightbox">
|
||||
<div class="lightbox-content">
|
||||
<span class="lightbox-close">×</span>
|
||||
<p class="lightbox-quote">{safe_body}</p>
|
||||
{attribution}
|
||||
<span class="lightbox-hint">CLICK ANYWHERE TO DISMISS</span>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
js = f"""
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
if (!localStorage.getItem('music-{slug}-seen')) {{
|
||||
lightbox.style.display = 'flex';
|
||||
localStorage.setItem('music-{slug}-seen', '1');
|
||||
}}
|
||||
const closeLightbox = () => {{ lightbox.style.display = 'none'; }};
|
||||
lightbox.addEventListener('click', (e) => {{
|
||||
if (e.target === lightbox) closeLightbox();
|
||||
}});
|
||||
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
}});
|
||||
"""
|
||||
return div, js
|
||||
|
||||
|
||||
def build_video_card(entry: dict) -> str:
|
||||
embed_url = f"https://www.youtube.com/embed/{entry['video_id']}"
|
||||
title_esc = escape(entry['name'])
|
||||
return f"""
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">{title_esc}</h2></div>
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
data-src="{embed_url}"
|
||||
allow="{ALLOW}"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
|
||||
def build_audio_card(entry: dict, audio_index: int) -> str:
|
||||
title_esc = escape(entry['name'])
|
||||
direct_url = entry['direct_url']
|
||||
share_url = entry['share_url']
|
||||
return f"""
|
||||
<div class="card" id="track-{audio_index}">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">{title_esc}</h2>
|
||||
</div>
|
||||
<div class="audio-card">
|
||||
<div class="audio-controls">
|
||||
<button class="play-btn"
|
||||
data-src="{direct_url}"
|
||||
data-title="{title_esc}">▶</button>
|
||||
<div class="audio-info">
|
||||
<a href="{share_url}" target="_blank" rel="noopener"
|
||||
class="download-link">⬇ DOWNLOAD</a>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
<div class="duration-meta">
|
||||
<span class="current-time">0:00</span>
|
||||
<span class="total-time">--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
|
||||
def build_entries_html(entries: list) -> str:
|
||||
parts = []
|
||||
audio_idx = 1
|
||||
for e in entries:
|
||||
if e['type'] == 'youtube':
|
||||
parts.append(build_video_card(e))
|
||||
else:
|
||||
parts.append(build_audio_card(e, audio_idx))
|
||||
audio_idx += 1
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def build_stagger_css(count: int) -> str:
|
||||
lines = []
|
||||
for i in range(1, min(count, 12) + 1):
|
||||
lines.append(
|
||||
f" .card:nth-child({i}) {{ animation-delay: {(i-1)*50}ms; }}"
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ── Full page generation ──────────────────────────────────────────────────────
|
||||
|
||||
def generate_html(data: dict, slug: str) -> str:
|
||||
title = data['title']
|
||||
entries = data['entries']
|
||||
count = len(entries)
|
||||
has_audio = any(e['type'] == 'audio' for e in entries)
|
||||
title_upper = title.upper()
|
||||
count_label = f"{count} TRACK{'S' if count != 1 else ''}"
|
||||
|
||||
lightbox_div, lightbox_js = build_lightbox_html(data['lightbox'], slug)
|
||||
entries_html = build_entries_html(entries)
|
||||
stagger_css = build_stagger_css(count)
|
||||
|
||||
lightbox_css = """
|
||||
/* --- Lightbox --- */
|
||||
#lightbox {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(4, 6, 11, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightbox-content {
|
||||
background: #0c0a08;
|
||||
border: 2px solid var(--mu-red);
|
||||
border-top: 8px solid var(--mu-orange);
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 2.5rem;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 0.5rem; right: 0.75rem;
|
||||
font-size: 2rem;
|
||||
color: var(--mu-orange);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.lightbox-quote {
|
||||
font-style: italic;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-warm);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.lightbox-attribution {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--mu-orange);
|
||||
text-align: right;
|
||||
}
|
||||
.lightbox-hint {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(232, 213, 184, 0.4);
|
||||
}
|
||||
""" if data['lightbox'] else ''
|
||||
|
||||
audio_css = """
|
||||
/* --- Audio Card --- */
|
||||
.audio-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.play-btn {
|
||||
background: none;
|
||||
border: 2px solid var(--mu-orange);
|
||||
color: var(--mu-orange);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
.play-btn:hover {
|
||||
background: var(--mu-orange);
|
||||
color: var(--bg-void);
|
||||
}
|
||||
.audio-info { flex-grow: 1; }
|
||||
.download-link {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mu-orange);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.progress-container {
|
||||
height: 4px;
|
||||
background: var(--mu-red);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--mu-orange);
|
||||
width: 0%;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
.duration-meta {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 213, 184, 0.5);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* --- Floating Player --- */
|
||||
.floating-player {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: var(--bg-void);
|
||||
border-top: 2px solid var(--mu-red);
|
||||
z-index: 500;
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.floating-player.active { display: flex; }
|
||||
.fp-info { flex: 1; min-width: 0; }
|
||||
.fp-title {
|
||||
font-family: 'Rancho', cursive;
|
||||
font-size: 1.2rem;
|
||||
color: var(--mu-orange);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.fp-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--mu-orange);
|
||||
font-family: 'Rambla', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.fp-btn:hover { color: var(--text-warm); }
|
||||
.fp-progress-wrap {
|
||||
position: absolute;
|
||||
top: -2px; left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--mu-red);
|
||||
}
|
||||
.fp-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--mu-orange);
|
||||
width: 0%;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
""" if has_audio else ''
|
||||
|
||||
floating_player_html = """
|
||||
<div class="floating-player">
|
||||
<div class="fp-progress-wrap"><div class="fp-progress-fill"></div></div>
|
||||
<div class="fp-info"><div class="fp-title">No Track Selected</div></div>
|
||||
<div class="fp-controls">
|
||||
<button class="fp-btn" id="fp-prev">◀◀</button>
|
||||
<button class="fp-btn" id="fp-play">▶</button>
|
||||
<button class="fp-btn" id="fp-next">▶▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<audio id="main-audio"></audio>""" if has_audio else ''
|
||||
|
||||
audio_js = """
|
||||
// --- Audio Logic ---
|
||||
const audio = document.getElementById('main-audio');
|
||||
const floatingPlayer = document.querySelector('.floating-player');
|
||||
const fpPlayBtn = document.getElementById('fp-play');
|
||||
const fpTitle = document.querySelector('.fp-title');
|
||||
const fpFill = document.querySelector('.fp-progress-fill');
|
||||
let currentTrackBtn = null;
|
||||
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const updateUI = () => {
|
||||
const playing = !audio.paused;
|
||||
const icon = playing ? '▐▐' : '▶';
|
||||
if (currentTrackBtn) currentTrackBtn.textContent = icon;
|
||||
fpPlayBtn.textContent = icon;
|
||||
if (playing) {
|
||||
floatingPlayer.classList.add('active');
|
||||
document.body.classList.add('player-active');
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.play-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const src = btn.dataset.src;
|
||||
const title = btn.dataset.title;
|
||||
if (audio.getAttribute('data-loaded') === src) {
|
||||
if (audio.paused) audio.play(); else audio.pause();
|
||||
} else {
|
||||
if (currentTrackBtn) currentTrackBtn.textContent = '▶';
|
||||
audio.src = src;
|
||||
audio.setAttribute('data-loaded', src);
|
||||
fpTitle.textContent = title;
|
||||
currentTrackBtn = btn;
|
||||
audio.play();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fpPlayBtn.addEventListener('click', () => {
|
||||
if (audio.paused) audio.play(); else audio.pause();
|
||||
});
|
||||
|
||||
audio.addEventListener('play', updateUI);
|
||||
audio.addEventListener('pause', updateUI);
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (!audio.duration) return;
|
||||
const pct = (audio.currentTime / audio.duration) * 100;
|
||||
if (fpFill) fpFill.style.width = pct + '%';
|
||||
if (currentTrackBtn) {
|
||||
const card = currentTrackBtn.closest('.audio-card');
|
||||
const bar = card.querySelector('.progress-bar');
|
||||
const cur = card.querySelector('.current-time');
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (cur) cur.textContent = formatTime(audio.currentTime);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (currentTrackBtn) {
|
||||
const card = currentTrackBtn.closest('.audio-card');
|
||||
const tot = card.querySelector('.total-time');
|
||||
if (tot) tot.textContent = formatTime(audio.duration);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
const allBtns = [...document.querySelectorAll('.play-btn')];
|
||||
const idx = allBtns.indexOf(currentTrackBtn);
|
||||
const next = allBtns[idx + 1];
|
||||
if (next) {
|
||||
next.click();
|
||||
} else {
|
||||
audio.pause();
|
||||
if (currentTrackBtn) currentTrackBtn.textContent = '▶';
|
||||
fpPlayBtn.textContent = '▶';
|
||||
floatingPlayer.classList.remove('active');
|
||||
document.body.classList.remove('player-active');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('fp-prev').addEventListener('click', () => {
|
||||
const all = [...document.querySelectorAll('.play-btn')];
|
||||
const prev = all[all.indexOf(currentTrackBtn) - 1];
|
||||
if (prev) prev.click();
|
||||
});
|
||||
|
||||
document.getElementById('fp-next').addEventListener('click', () => {
|
||||
const all = [...document.querySelectorAll('.play-btn')];
|
||||
const next = all[all.indexOf(currentTrackBtn) + 1];
|
||||
if (next) next.click();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.progress-container').forEach(bar => {
|
||||
bar.addEventListener('click', (e) => {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||
});
|
||||
});
|
||||
""" if has_audio 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)} | MUSIC</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=Rancho&family=Rambla:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {{
|
||||
--bg-void: #04060b;
|
||||
--text-warm: #e8d5b8;
|
||||
--mu-red: #8b2020;
|
||||
--mu-orange: #ff7f3f;
|
||||
--mu-red-glow: rgba(139, 32, 32, 0.25);
|
||||
--mu-orange-glow: rgba(255, 127, 63, 0.25);
|
||||
--transition: 100ms ease;
|
||||
}}
|
||||
|
||||
* {{ 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.6;
|
||||
padding-top: 64px;
|
||||
}}
|
||||
|
||||
body.player-active {{ padding-bottom: 64px; }}
|
||||
|
||||
/* --- Sticky header --- */
|
||||
.sticky-header {{
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-void);
|
||||
border-bottom: 2px solid var(--mu-red);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}}
|
||||
|
||||
.header-title {{
|
||||
font-family: 'Rancho', cursive;
|
||||
font-size: 1.5rem;
|
||||
color: var(--mu-orange);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 1rem;
|
||||
}}
|
||||
|
||||
.header-nav {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}}
|
||||
|
||||
.nav-link {{
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
color: var(--mu-orange);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}}
|
||||
|
||||
.nav-link:hover {{ color: #ff9f6f; }}
|
||||
|
||||
.count {{
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(232, 213, 184, 0.5);
|
||||
}}
|
||||
|
||||
.menu-toggle {{
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--mu-orange);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}}
|
||||
|
||||
@media (max-width: 600px) {{
|
||||
.menu-toggle {{ display: block; }}
|
||||
.header-nav {{
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%; right: 0;
|
||||
background: var(--bg-void);
|
||||
border: 2px solid var(--mu-red);
|
||||
border-top: none;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
width: 200px;
|
||||
}}
|
||||
.header-nav.open {{ display: flex; }}
|
||||
}}
|
||||
{lightbox_css}{audio_css}
|
||||
/* --- Cards --- */
|
||||
@keyframes cardIn {{
|
||||
from {{ opacity: 0; transform: translateY(16px); }}
|
||||
to {{ opacity: 1; transform: translateY(0); }}
|
||||
}}
|
||||
|
||||
main {{
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 4rem;
|
||||
}}
|
||||
|
||||
.section-title {{
|
||||
font-family: 'Rancho', cursive;
|
||||
font-size: 3rem;
|
||||
color: var(--mu-orange);
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.card {{
|
||||
margin-bottom: 3rem;
|
||||
border: 2px solid var(--mu-red);
|
||||
background: rgba(139, 32, 32, 0.05);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
animation: cardIn 320ms ease both;
|
||||
}}
|
||||
|
||||
{stagger_css}
|
||||
|
||||
.card:hover {{
|
||||
border-color: var(--mu-orange);
|
||||
box-shadow: 0 0 8px 2px var(--mu-orange-glow);
|
||||
}}
|
||||
|
||||
.card-header {{
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--mu-red);
|
||||
}}
|
||||
|
||||
.card-title {{
|
||||
font-family: 'Rancho', cursive;
|
||||
font-size: 1.6rem;
|
||||
color: var(--mu-orange);
|
||||
}}
|
||||
|
||||
.video-container {{
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}}
|
||||
|
||||
.video-container iframe {{
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
border: 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{lightbox_div}
|
||||
<header class="sticky-header">
|
||||
<div class="header-title">{escape(title_upper)}</div>
|
||||
<button class="menu-toggle">☰</button>
|
||||
<nav class="header-nav">
|
||||
<a href="music-hub.html" class="nav-link">← MUSIC</a>
|
||||
<span class="count">{count_label}</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1 class="section-title">{escape(title.upper())}</h1>
|
||||
{entries_html}
|
||||
</main>
|
||||
{floating_player_html}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
{lightbox_js}
|
||||
// --- Mobile nav ---
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const nav = document.querySelector('.header-nav');
|
||||
menuToggle.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
nav.classList.toggle('open');
|
||||
}});
|
||||
document.addEventListener('click', (e) => {{
|
||||
if (!nav.contains(e.target) && e.target !== menuToggle)
|
||||
nav.classList.remove('open');
|
||||
}});
|
||||
|
||||
// --- Lazy-load iframes ---
|
||||
const observer = new IntersectionObserver((entries, obs) => {{
|
||||
entries.forEach(entry => {{
|
||||
if (entry.isIntersecting) {{
|
||||
const iframe = entry.target;
|
||||
if (iframe.dataset.src) {{
|
||||
iframe.src = iframe.dataset.src;
|
||||
iframe.removeAttribute('data-src');
|
||||
obs.unobserve(iframe);
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
}}, {{ rootMargin: '200px' }});
|
||||
document.querySelectorAll('iframe[data-src]').forEach(f => observer.observe(f));
|
||||
{audio_js}
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ── Hub subtitle update ───────────────────────────────────────────────────────
|
||||
|
||||
def update_hub_subtitle(collection_count: int, entry_total: int):
|
||||
hub_text = HUB.read_text(encoding='utf-8')
|
||||
subtitle = (
|
||||
f'<span class="subtitle">'
|
||||
f'{collection_count} COLLECTION{"S" if collection_count != 1 else ""}'
|
||||
f' · PERSONAL ARCHIVE'
|
||||
f' · {entry_total} TRACK{"S" if entry_total != 1 else ""}'
|
||||
f'</span>'
|
||||
)
|
||||
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('*-music.md'))
|
||||
if not md_files:
|
||||
print("No *-music.md files found.")
|
||||
return
|
||||
|
||||
total_entries = 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['entries'])
|
||||
|
||||
if count == 0:
|
||||
print(f" SKIP {md_path.name} — no entries found")
|
||||
continue
|
||||
|
||||
audio_count = sum(1 for e in data['entries'] if e['type'] == 'audio')
|
||||
video_count = count - audio_count
|
||||
|
||||
html = generate_html(data, slug)
|
||||
out.write_text(html, encoding='utf-8')
|
||||
total_entries += count
|
||||
print(f" BUILD {out.name} — {data['title']} ({video_count} videos, {audio_count} audio)")
|
||||
|
||||
update_hub_subtitle(len(md_files), total_entries)
|
||||
print(f"\nUpdated hub — {len(md_files)} collections, {total_entries} tracks total")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user