aboutsummaryrefslogtreecommitdiff
path: root/index.js
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-28 18:25:41 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-28 18:25:41 -0700
commit34f0486f091ded7112442a00f8c5c5ecae342265 (patch)
treee00129732e44609e76bcb22b25d2a18af5aa1df2 /index.js
parent7b67d90f4f25eca2480d0d769ec5ab6305c4b4c6 (diff)
refactor: extract inline css/js from index.html
index.html shrank from 1650 to 349 lines. Inline <style> moved to index.css, ARTICLES data to articles.js, the rest of the inline script to index.js. Also: - drop unused bindMusicToggle from aero.js - collapse duplicate click+dblclick icon handlers - dedup music-toggle gradient via .mt-btn.on class - replace inline styles in films/podcast/now-playing renderers with semantic classes (.film-when, .pod-ep, .np-row, etc.) - precompute article readMin and plainTitle once instead of on every render Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'index.js')
-rw-r--r--index.js431
1 files changed, 431 insertions, 0 deletions
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..879faf1
--- /dev/null
+++ b/index.js
@@ -0,0 +1,431 @@
+ Aero.initTheme();
+ const desk = document.getElementById('desk');
+ Aero.makeClouds(document.getElementById('clouds'));
+ Aero.spawnBubbles(desk, 24);
+ Aero.sparkleCursor();
+ Aero.mountThemeSwitcher();
+
+ document.querySelectorAll('.win').forEach(w => {
+ Aero.makeDraggable(w, w.querySelector('.titlebar'));
+ Aero.makeResizable(w, { minW: 300, minH: 220 });
+ });
+
+ // Sticky note — draggable, lightly random tilt so each load feels handmade
+ const sticky = document.getElementById('sticky');
+ if (sticky) {
+ const rot = (-4 + Math.random() * 3).toFixed(2);
+ sticky.style.transform = `rotate(${rot}deg)`;
+ Aero.makeDraggable(sticky, sticky);
+ }
+
+ // Webring — silly old-internet behaviour
+ const wrPrev = document.getElementById('wr-prev');
+ const wrNext = document.getElementById('wr-next');
+ const wrRand = document.getElementById('wr-rand');
+ const wrCount = document.getElementById('wr-count');
+ const ringSites = [
+ 'mireia.computer', 'jaylim.fm', 'trinh.computer', 'arielab.dev',
+ 'linhwrites.net', 'marco.audio', 'smallgreenpix.org', 'amalia.zone',
+ 'thursday.cafe', 'soft.garden', 'foglamp.club', 'kanji.coffee'
+ ];
+ let ringIdx = 42;
+ function ringHop(dir) {
+ ringIdx = Math.max(1, Math.min(184, ringIdx + dir));
+ wrCount.textContent = `site ${ringIdx} / 184`;
+ const site = ringSites[Math.floor(Math.random() * ringSites.length)];
+ wrCount.title = `would surf to ${site}`;
+ wrCount.animate(
+ [{ opacity: 0.3 }, { opacity: 0.75 }],
+ { duration: 280, easing: 'ease-out' }
+ );
+ }
+ if (wrPrev) wrPrev.addEventListener('click', e => { e.preventDefault(); ringHop(-1); });
+ if (wrNext) wrNext.addEventListener('click', e => { e.preventDefault(); ringHop(1); });
+ if (wrRand) wrRand.addEventListener('click', e => {
+ e.preventDefault();
+ ringIdx = 1 + Math.floor(Math.random() * 184);
+ wrCount.textContent = `site ${ringIdx} / 184`;
+ wrCount.animate(
+ [{ transform: 'scale(0.9)', opacity: 0.3 }, { transform: 'scale(1)', opacity: 0.75 }],
+ { duration: 320, easing: 'ease-out' }
+ );
+ });
+
+ document.querySelectorAll('.icon').forEach(ic => {
+ ic.addEventListener('click', () => {
+ const w = document.getElementById('w-' + ic.dataset.open);
+ if (w) { w.style.display = ''; w.style.zIndex = (++window.__zTop || (window.__zTop = 100)); }
+ });
+ });
+
+ // podcast RSS
+ let podLoaded = false;
+ async function loadPodcast() {
+ if (podLoaded) return;
+ podLoaded = true;
+ try {
+ const { art, episodes } = await Aero.fetchReelMouthFeed(6);
+ if (art) {
+ const img = document.getElementById('pod-art');
+ const ph = document.getElementById('pod-art-placeholder');
+ img.src = art;
+ img.style.display = '';
+ if (ph) ph.style.display = 'none';
+ }
+ const container = document.getElementById('pod-episodes');
+ if (episodes.length) {
+ container.innerHTML = episodes.map(e =>
+ `<div class="pod-ep">
+ <a href="${e.url}" target="_blank" rel="noopener" title="${e.title}">${e.title.toLowerCase()}</a>
+ <span class="pod-dur">${e.duration}</span>
+ </div>`
+ ).join('');
+ } else {
+ container.innerHTML = '<div class="empty-msg">no episodes found</div>';
+ }
+ } catch (err) {
+ document.getElementById('pod-episodes').innerHTML = '<div class="empty-msg">couldn\'t load feed</div>';
+ }
+ }
+ document.querySelectorAll('.icon[data-open="podcast"]').forEach(ic => {
+ ic.addEventListener('click', loadPodcast);
+ });
+
+ // music toggle + volume slider (theme-aware)
+ const MUSIC = {
+ aero: { src: '/mus/bazaar-theme.mp3', label: '♪ a-dog — bazaar theme' },
+ chrome: { src: '/mus/CoolMan - WhoIsUsingThisComputer_.mp3', label: '♪ coolman — who is using this computer?' },
+ };
+ document.getElementById('mt').innerHTML = Aero.musicToggleHTML();
+ const mtDiv = document.getElementById('mt');
+ const mtBtn = mtDiv.querySelector('.mt-btn');
+ const mtLabel = mtDiv.querySelector('.mt-label');
+ const bgm = document.getElementById('bgm');
+ bgm.volume = 0.2;
+
+ const vol = document.createElement('input');
+ vol.type = 'range'; vol.min = '0'; vol.max = '1'; vol.step = '0.01'; vol.value = '0.2';
+ vol.className = 'no-drag aero-vol';
+ vol.title = 'volume';
+ vol.style.cssText = 'width:70px;margin-left:6px;cursor:pointer;accent-color:oklch(55% 0.13 230);vertical-align:middle;';
+ mtDiv.querySelector('.music-toggle').appendChild(vol);
+ vol.addEventListener('input', () => { bgm.volume = Number(vol.value); });
+
+ let musicOn = false;
+
+ function setMusicUI(on, label) {
+ musicOn = on;
+ mtBtn.textContent = on ? '❚❚' : '▶';
+ mtBtn.classList.toggle('on', on);
+ mtLabel.textContent = on ? label : 'music off';
+ }
+
+ function playCurrentTrack() {
+ const track = MUSIC[Aero.getTheme()] || MUSIC.aero;
+ bgm.src = track.src;
+ bgm.load();
+ return bgm.play().then(() => setMusicUI(true, track.label));
+ }
+
+ function applyMusicTheme() {
+ const track = MUSIC[Aero.getTheme()] || MUSIC.aero;
+ if (bgm.getAttribute('src') !== track.src) {
+ bgm.src = track.src;
+ if (musicOn) { bgm.load(); bgm.currentTime = 0; bgm.play(); }
+ }
+ if (musicOn) mtLabel.textContent = track.label;
+ }
+
+ mtBtn.addEventListener('click', () => {
+ if (musicOn) { bgm.pause(); setMusicUI(false); }
+ else { playCurrentTrack(); }
+ });
+
+ // swap track live when theme changes while music is playing
+ window.addEventListener('themechange', applyMusicTheme);
+
+ // autoplay on load; fall back to first click if browser blocks it
+ playCurrentTrack().catch(() => {
+ document.addEventListener('click', () => { playCurrentTrack(); }, { once: true });
+ });
+
+ // counter
+ document.getElementById('cc').innerHTML = Aero.counterHTML(0, 'visitors');
+ Aero.fetchVisitorCount()
+ .then(n => {
+ document.getElementById('cc').innerHTML = Aero.counterHTML(n, 'visitors');
+ })
+ .catch(() => {});
+
+ // now playing
+ document.getElementById('np-card').innerHTML = Aero.nowPlayingHTML(false);
+ Aero.animateEq(document.getElementById('np-card'));
+
+ // fetch last.fm
+ Aero.fetchLastFm()
+ .then(tracks => {
+ if (tracks && tracks.length > 0) {
+ document.getElementById('np-card').innerHTML = Aero.nowPlayingHTML(false, tracks[0]);
+ Aero.animateEq(document.getElementById('np-card'));
+
+ const recentDiv = document.getElementById('np-recent');
+ if (tracks.length > 1) {
+ recentDiv.innerHTML = tracks.slice(1, 4).map(t => {
+ const ago = t.when ? Math.floor((Date.now() - t.when) / 60000) : 0;
+ const timeStr = ago < 60 ? ago + 'm' : Math.floor(ago / 60) + 'h';
+ return `<div class="np-row"><span>${t.artist ? t.artist + ' — ' : ''}${t.name}</span><span class="np-ago">${timeStr}</span></div>`;
+ }).join('');
+ }
+ }
+ })
+ .catch(() => {});
+
+ // clock
+ function tick() {
+ const d = new Date();
+ document.getElementById('clock').textContent =
+ d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ }
+ tick(); setInterval(tick, 30000);
+
+ // guestbook send (fake but cute)
+ const gbBtn = document.getElementById('gb-send');
+ if (gbBtn) {
+ gbBtn.addEventListener('click', async () => {
+ const name = document.getElementById('gb-name').value.trim();
+ const email = document.getElementById('gb-email').value.trim();
+ const message = document.getElementById('gb-msg').value.trim();
+ const status = document.getElementById('gb-status');
+ if (!name || !email || !message) { status.textContent = '— fill in all three fields —'; return; }
+ status.textContent = 'sending…';
+ gbBtn.disabled = true;
+ try {
+ const res = await fetch('https://tylerhoang.xyz/api/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email, message }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (res.ok) {
+ status.textContent = '✓ sent! talk soon';
+ document.getElementById('gb-name').value = '';
+ document.getElementById('gb-email').value = '';
+ document.getElementById('gb-msg').value = '';
+ } else {
+ status.textContent = data.error || '✗ something went wrong';
+ }
+ } catch {
+ status.textContent = '✗ could not reach server';
+ } finally {
+ gbBtn.disabled = false;
+ setTimeout(() => { status.textContent = '— takes ~2 days for a reply —'; }, 5000);
+ }
+ });
+ }
+
+ /* ============= FAUX BROWSER =============
+ Articles live in the ARTICLES array below.
+ Each `body` is plain HTML — use <p>, <h2>, <blockquote>, <em>, <code>,
+ and <span class="ilink" data-go="/articles/slug"> for in-page links.
+ ============================================== */
+
+ ARTICLES.forEach(a => {
+ const words = a.body.replace(/<[^>]+>/g, '').split(/\s+/).length;
+ a.readMin = Math.max(2, Math.round(words / 200));
+ a.plainTitle = a.title.replace(/<[^>]+>/g, '');
+ });
+ const ARTICLE_INDEX = Object.fromEntries(ARTICLES.map(a => [a.slug, a]));
+ const HOST = 'fun.tylerhoang.xyz';
+ const browserHistory = { stack: [], idx: -1 };
+
+ function fmtDate(iso) {
+ const d = new Date(iso);
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toLowerCase();
+ }
+
+ function renderIndex() {
+ const items = ARTICLES.map((a, i) => `
+ <div class="idx-art" data-go="/articles/${a.slug}">
+ <div class="idx-art-num">${String(i + 1).padStart(2, '0')}</div>
+ <div>
+ <div class="idx-art-title">${a.title}</div>
+ <div class="idx-art-excerpt">${a.excerpt}</div>
+ </div>
+ <div class="idx-art-meta">
+ <div>${fmtDate(a.date)}</div>
+ <div><span class="idx-art-tag">${a.tag}</span></div>
+ </div>
+ </div>
+ `).join('');
+ return `
+ <div class="idx-hero">
+ <div class="idx-eyebrow">/notes — Tyler Hoang</div>
+ <h1 class="idx-title">Things I've been <em>thinking about</em>.</h1>
+ <p class="idx-sub">Half-finished essays from a banker who'd rather be playing piano. New ones land when they land — usually monthly, never on a schedule.</p>
+ </div>
+ <div class="idx-list">${items}</div>
+ `;
+ }
+
+ function renderArticle(slug) {
+ const a = ARTICLE_INDEX[slug];
+ if (!a) return `<div class="art-page"><h1 class="art-title">404 — not found</h1><p>That page isn't here.</p><span class="art-back" data-go="/articles">← back to notes</span></div>`;
+ const i = ARTICLES.findIndex(x => x.slug === slug);
+ const next = ARTICLES[(i + 1) % ARTICLES.length];
+ return `
+ <article class="art-page">
+ <span class="art-back" data-go="/articles">← all notes</span>
+ <div class="art-eyebrow">${a.tag} · ${fmtDate(a.date)}</div>
+ <h1 class="art-title">${a.title}</h1>
+ <div class="art-byline">by Tyler Hoang · ~${a.readMin} min read</div>
+ <div class="art-body">${a.body}</div>
+ <div class="art-foot">
+ <span>↑ thanks for reading</span>
+ <span class="more" data-go="/articles/${next.slug}">next: ${next.plainTitle} →</span>
+ </div>
+ </article>
+ `;
+ }
+
+ function pathToContent(path) {
+ if (path === '/articles' || path === '/articles/') return renderIndex();
+ const m = path.match(/^\/articles\/([a-z0-9-]+)$/);
+ if (m) return renderArticle(m[1]);
+ return `<div class="art-page"><h1 class="art-title">404</h1><p>No page at <code>${path}</code>.</p><span class="art-back" data-go="/articles">← back</span></div>`;
+ }
+
+ function updateChrome(path) {
+ document.getElementById('br-host').textContent = HOST;
+ document.getElementById('br-path').textContent = path;
+ const isArt = /^\/articles\/[a-z0-9-]+$/.test(path);
+ const slug = isArt ? path.split('/').pop() : null;
+ const titleTxt = isArt && ARTICLE_INDEX[slug]
+ ? `${ARTICLE_INDEX[slug].plainTitle} — fun.tylerhoang.xyz`
+ : 'Notes — fun.tylerhoang.xyz';
+ document.getElementById('br-title').textContent = titleTxt;
+ document.querySelectorAll('.browser-bookmarks .bm').forEach(bm => {
+ bm.classList.toggle('active', bm.dataset.go === path);
+ });
+ document.getElementById('br-back').disabled = browserHistory.idx <= 0;
+ document.getElementById('br-fwd').disabled = browserHistory.idx >= browserHistory.stack.length - 1;
+ }
+
+ function loadPath(path, pushHist = true) {
+ const page = document.getElementById('br-page');
+ const status = document.getElementById('br-status');
+ const progress = document.getElementById('br-progress');
+ status.textContent = `Contacting fun.tylerhoang.xyz…`;
+ progress.style.display = 'block';
+ progress.querySelector('.progress-bar').style.width = '20%';
+ setTimeout(() => {
+ progress.querySelector('.progress-bar').style.width = '70%';
+ status.textContent = `Reading ${path}…`;
+ }, 80);
+ setTimeout(() => {
+ page.innerHTML = pathToContent(path);
+ page.scrollTop = 0;
+ if (pushHist) {
+ browserHistory.stack = browserHistory.stack.slice(0, browserHistory.idx + 1);
+ browserHistory.stack.push(path);
+ browserHistory.idx = browserHistory.stack.length - 1;
+ }
+ updateChrome(path);
+ progress.querySelector('.progress-bar').style.width = '100%';
+ setTimeout(() => {
+ progress.style.display = 'none';
+ progress.querySelector('.progress-bar').style.width = '0%';
+ status.textContent = 'Done';
+ }, 120);
+ }, 220);
+ }
+
+ // delegated click handler — anything with data-go inside the browser navigates
+ document.getElementById('w-browser').addEventListener('click', (e) => {
+ const t = e.target.closest('[data-go]');
+ if (t) { e.preventDefault(); loadPath(t.dataset.go); }
+ });
+ document.getElementById('br-back').addEventListener('click', () => {
+ if (browserHistory.idx > 0) { browserHistory.idx--; loadPath(browserHistory.stack[browserHistory.idx], false); }
+ });
+ document.getElementById('br-fwd').addEventListener('click', () => {
+ if (browserHistory.idx < browserHistory.stack.length - 1) { browserHistory.idx++; loadPath(browserHistory.stack[browserHistory.idx], false); }
+ });
+ document.getElementById('br-home').addEventListener('click', () => loadPath('/articles'));
+ document.getElementById('br-go').addEventListener('click', () => {
+ loadPath(document.getElementById('br-path').textContent.trim() || '/articles');
+ });
+ document.getElementById('br-reload').addEventListener('click', (e) => {
+ const btn = e.currentTarget;
+ btn.classList.remove('spinning'); void btn.offsetWidth; btn.classList.add('spinning');
+ loadPath(browserHistory.stack[browserHistory.idx] || '/articles', false);
+ });
+ // initial page load
+ loadPath('/articles');
+
+ // fetch films
+ Aero.fetchFilms()
+ .then(data => {
+ try {
+ const films = Array.isArray(data) ? data : (data.films || data.data || []);
+ const filmsList = document.getElementById('films-list');
+
+ if (films.length > 0) {
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+ filmsList.innerHTML = films.slice(0, 4).map((film) => {
+ const rating = Math.max(0, Math.min(3, Number(film.stars ?? film.rating ?? 0)));
+ const stars = Array.from({ length: 3 }, (_, i) =>
+ i < rating ? '<span class="star on">★</span>' : '<span class="star">★</span>'
+ ).join('');
+
+ let when = '';
+ const raw = film.date_watched || film.watchedAt;
+ if (raw) {
+ const d = new Date(raw);
+ if (!isNaN(d)) {
+ const days = Math.floor((Date.now() - d) / 86400000);
+ if (days <= 0) when = 'today';
+ else if (days === 1) when = '1d ago';
+ else if (days < 7) when = days + 'd ago';
+ else if (days < 30) when = Math.floor(days / 7) + 'w ago';
+ else when = Math.floor(days / 30) + 'mo ago';
+ } else {
+ when = String(raw);
+ }
+ }
+
+ const poster = film.poster_url || film.posterUrl;
+ const posterStyle = poster
+ ? `background: url('${esc(poster).replace(/'/g, '%27')}') center / cover;`
+ : `background: linear-gradient(135deg, oklch(70% 0.13 220), oklch(40% 0.10 250));`;
+
+ const sub = film.director ? esc(film.director) : (film.note ? esc(film.note) : '');
+
+ return `
+ <div class="film-row">
+ <div class="film-poster" style="${posterStyle}"></div>
+ <div class="film-meta">
+ <div class="film-title">${esc(film.title)} <span class="film-year">${esc(film.year || '')}</span></div>
+ <div class="film-rating">${stars}<span class="film-when">${when}</span></div>
+ ${sub ? `<div class="film-note">${sub}</div>` : ''}
+ </div>
+ </div>
+ `;
+ }).join('');
+ }
+
+ const statsDiv = document.getElementById('films-stats');
+ if (data.total !== undefined || data.count !== undefined) {
+ const total = data.total || data.count;
+ let statsHtml = `<div><strong>${total}</strong> watched</div>`;
+ if (data.thisYear !== undefined) {
+ statsHtml += `<div><strong>${data.thisYear}</strong> this year</div>`;
+ }
+ statsDiv.innerHTML = statsHtml;
+ }
+ } catch (err) {
+ console.error('Films parse error:', err);
+ }
+ })
+ .catch(err => {
+ console.error('Films fetch error:', err);
+ });