diff options
Diffstat (limited to 'index.js')
| -rw-r--r-- | index.js | 431 |
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); + }); |
