src/assets/app.js
16,469 bytes · 369 lines · capsule://quake0day/[email protected]
raw on github
/* ==========================================================================
Yingjie Li — Online Gallery
Dynamic data from /api/data with static fallback baked into the file.
========================================================================== */
(() => {
'use strict';
// ============================================================
// 0. Theme toggle (light / dark) with persistence
// ============================================================
const THEME_KEY = 'yl-theme';
const root = document.documentElement;
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') root.dataset.theme = stored;
const themeBtn = document.getElementById('theme-toggle');
if (themeBtn) {
themeBtn.addEventListener('click', () => {
const next = (root.dataset.theme === 'dark') ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem(THEME_KEY, next);
});
}
// ============================================================
// 1. Static fallback data (used while /api/data loads)
// ============================================================
const FALLBACK = {
hero: { image: 'rowing_tea_party.jpg', title: 'Rowing Tea Party', year: 2023, num: '020' },
bio: {
quote: 'Painting is the way I keep <em>the small things</em> from disappearing.',
paragraphs: [] // bio body stays static in HTML for SEO
},
exhibitions: [],
contact: {
email: '[email protected]',
etsy: 'https://www.etsy.com/shop/CuriousJCArt',
gallery: 'https://www.visualexpansiongallery.com/yingjie-li'
},
works: [
{ num:'001', file:'Art1_2013.jpg', title:'Moon Dancer', year:2013, w:1763, h:2267 },
{ num:'002', file:'Art2_2014.jpg', title:'Clock', year:2014, w:1275, h:1568 },
{ num:'003', file:'Art3_2014.jpg', title:'Pig in the Forest', year:2014, w:2465, h:1622 },
{ num:'004', file:'Art4_2015.jpg', title:'Once Upon a Time', year:2015, w:2183, h:1832 },
{ num:'005', file:'Art5_2016.jpg', title:'Girl on Pig', year:2016, w:1773, h:2255 },
{ num:'006', file:'Art6_2016.jpg', title:'Relief', year:2016, w:1773, h:2255 },
{ num:'007', file:'Art7_2016.jpg', title:'Siren', year:2016, w:1773, h:2255 },
{ num:'008', file:'Art8_2016.jpg', title:'Train Is Coming to Town', year:2016, w:2235, h:1789 },
{ num:'009', file:'Art9_2017.jpg', title:'Magic Forest', year:2017, w:1826, h:2190 },
{ num:'010', file:'Art10_2018.jpg', title:'Hide and Seek', year:2018, w:2705, h:3305 },
{ num:'011', file:'Art11_2018.jpg', title:'Tea Party', year:2018, w:1985, h:1655 },
{ num:'012', file:'Art12_2018.jpg', title:'T Is for Terrific Things', year:2018, w:1939, h:2061 },
{ num:'013', file:'a_friendly_recital.jpg', title:'A Friendly Recital', year:2023, w:1500, h:1500, gallery:true },
{ num:'014', file:'bubble_buddies.jpg', title:'Bubble Buddies', year:2023, w:1500, h:1500, gallery:true },
{ num:'015', file:'bunny_in_red.jpg', title:'Bunny in Red', year:2023, w:1500, h:2091, gallery:true },
{ num:'016', file:'candy_wagon.jpg', title:'Candy Wagon', year:2023, w:1500, h:1218, gallery:true },
{ num:'017', file:'forest_magic.jpg', title:'Forest Magic', year:2023, w:1500, h:1192, gallery:true },
{ num:'018', file:'music_in_the_forest.jpg', title:'Music in the Forest', year:2023, w:1500, h:1889, gallery:true },
{ num:'019', file:'pig_ride.jpg', title:'Pig Ride', year:2023, w:1493, h:2031, gallery:true },
{ num:'020', file:'rowing_tea_party.jpg', title:'Rowing Tea Party', year:2023, w:1438, h:1841, gallery:true },
{ num:'021', file:'rowing_with_a_friend.jpg',title:'Rowing With a Friend', year:2023, w:1500, h:1920, gallery:true },
{ num:'022', file:'sweet_dreams.jpg', title:'Sweet Dreams', year:2023, w:1500, h:2093, gallery:true },
{ num:'023', file:'here_have_a_sip.jpg', title:'Here, Have a Sip', year:2024, w:1968, h:1545, gallery:true }
]
};
let data = FALLBACK;
let works = data.works;
// ============================================================
// 2. Helpers
// ============================================================
function imgUrl(file) {
if (!file) return '';
if (file.startsWith('http') || file.startsWith('/')) return file;
return '/images/' + file; // bare name = built-in artwork
}
function romanize(num) {
const map = [['M',1000],['CM',900],['D',500],['CD',400],['C',100],['XC',90],['L',50],['XL',40],['X',10],['IX',9],['V',5],['IV',4],['I',1]];
let r = '';
for (const [l, v] of map) while (num >= v) { r += l; num -= v; }
return r;
}
function escapeHtml(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'})[c]); }
function escapeAttr(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'})[c]); }
// ============================================================
// 3. IntersectionObserver for reveals
// ============================================================
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.05 });
document.querySelectorAll('.reveal').forEach(el => io.observe(el));
// ============================================================
// 4. Lightbox
// ============================================================
const lb = document.getElementById('lightbox');
const lbImg = lb.querySelector('.lb-image');
const lbNum = lb.querySelector('.lb-num');
const lbTitle = lb.querySelector('.lb-title');
const lbYear = lb.querySelector('.lb-year');
const lbClose = lb.querySelector('.lb-close');
const lbPrev = lb.querySelector('.lb-prev');
const lbNext = lb.querySelector('.lb-next');
let workEls = [];
let currentIndex = 0;
let visibleWorks = [];
function getVisible() {
return workEls
.map((el, i) => ({ el, i }))
.filter(({ el }) => !el.classList.contains('is-hidden'))
.map(({ i }) => i);
}
function openLightbox(idx) {
visibleWorks = getVisible();
if (visibleWorks.length === 0) return;
if (!visibleWorks.includes(idx)) idx = visibleWorks[0];
currentIndex = idx;
updateLightbox();
lb.classList.add('is-open');
lb.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
lb.classList.remove('is-open');
lb.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function updateLightbox() {
const w = works[currentIndex];
if (!w) return;
lbImg.src = imgUrl(w.file);
lbImg.alt = `${w.title}, ${w.year}, by Yingjie Li`;
lbNum.textContent = `N° ${w.num}`;
lbTitle.innerHTML = `<em>${escapeHtml(w.title)}</em>`;
lbYear.textContent = `${romanize(w.year)} · ${w.year}`;
const vi = visibleWorks.indexOf(currentIndex);
[-1, 1].forEach(d => {
const next = visibleWorks[(vi + d + visibleWorks.length) % visibleWorks.length];
if (next !== undefined && works[next]) {
const img = new Image();
img.src = imgUrl(works[next].file);
}
});
}
function navigate(dir) {
const vi = visibleWorks.indexOf(currentIndex);
const ni = (vi + dir + visibleWorks.length) % visibleWorks.length;
currentIndex = visibleWorks[ni];
updateLightbox();
}
lbClose.addEventListener('click', closeLightbox);
lbPrev.addEventListener('click', () => navigate(-1));
lbNext.addEventListener('click', () => navigate(1));
lb.addEventListener('click', (e) => { if (e.target === lb) closeLightbox(); });
document.addEventListener('keydown', (e) => {
if (!lb.classList.contains('is-open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') navigate(-1);
if (e.key === 'ArrowRight') navigate(1);
});
let touchStartX = null;
lb.addEventListener('touchstart', (e) => { touchStartX = e.changedTouches[0].clientX; }, { passive: true });
lb.addEventListener('touchend', (e) => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 50) navigate(dx < 0 ? 1 : -1);
touchStartX = null;
}, { passive: true });
// ============================================================
// 5. Render gallery
// ============================================================
const galleryEl = document.getElementById('gallery');
function renderGallery() {
galleryEl.innerHTML = '';
works.forEach((w, i) => {
const fig = document.createElement('figure');
fig.className = 'work';
fig.dataset.year = w.year;
fig.dataset.index = i;
fig.style.transitionDelay = `${(i % 3) * 80}ms`;
const badge = w.gallery
? `<span class="wm-gallery" title="Currently on view at Visual Expansion Gallery">On view</span>`
: '';
fig.innerHTML = `
<div class="work-frame">
<img src="${imgUrl(w.file)}"
alt="${escapeAttr(w.title)}, ${w.year}, by Yingjie Li"
loading="lazy" decoding="async"${w.w && w.h ? ` width="${w.w}" height="${w.h}"` : ''} />
</div>
<figcaption class="work-meta">
<span class="wm-num">N° ${escapeHtml(w.num)}${badge}</span>
<span class="wm-title"><em>${escapeHtml(w.title)}</em></span>
<span class="wm-year">${romanize(w.year)}</span>
</figcaption>`;
galleryEl.appendChild(fig);
});
workEls = Array.from(galleryEl.querySelectorAll('.work'));
workEls.forEach((el, i) => el.addEventListener('click', () => openLightbox(i)));
workEls.forEach(el => io.observe(el));
applyActiveFilter();
}
renderGallery();
// ============================================================
// 6. Year filter
// ============================================================
let activeYear = 'all';
function applyActiveFilter() {
workEls.forEach(el => {
const y = activeYear;
let m;
if (y === 'all') m = true;
else if (y === 'recent') m = parseInt(el.dataset.year, 10) >= 2023;
else m = el.dataset.year === y;
el.classList.toggle('is-hidden', !m);
});
}
document.querySelectorAll('.chip').forEach(chip => {
chip.addEventListener('click', () => {
document.querySelectorAll('.chip').forEach(c => {
c.classList.toggle('is-active', c === chip);
c.setAttribute('aria-selected', c === chip ? 'true' : 'false');
});
activeYear = chip.dataset.year;
applyActiveFilter();
});
});
// ============================================================
// 7. Sticky header treatment
// ============================================================
const header = document.querySelector('.site-header');
function onScroll() { header.classList.toggle('is-scrolled', window.scrollY > 24); }
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
// ============================================================
// 8. Render hero (pulled from data.hero)
// ============================================================
const heroBgImg = document.querySelector('.hero-bg img');
const heroPreloadEl = document.querySelector('link[rel="preload"][as="image"]');
const heroNumEl = document.querySelector('.ha-num');
const heroTitleEl = document.querySelector('.ha-title');
function renderHero() {
const h = data.hero;
if (!h) return;
const url = imgUrl(h.file || h.image);
if (heroBgImg && heroBgImg.getAttribute('src') !== url) heroBgImg.src = url;
if (heroPreloadEl) heroPreloadEl.href = url;
if (heroNumEl) heroNumEl.textContent = 'N° ' + (h.num || '—');
if (heroTitleEl) heroTitleEl.innerHTML = `<em>${escapeHtml(h.title || '')}</em>${h.year ? ', ' + h.year : ''}`;
}
renderHero();
// ============================================================
// 9. Render bio (paragraphs + quote)
// ============================================================
function renderBio() {
const bio = data.bio || {};
const quoteEl = document.querySelector('.aside-quote');
if (quoteEl && bio.quote) {
quoteEl.innerHTML = `<span class="quote-mark" aria-hidden="true">“</span>${bio.quote}`;
}
const bodyEl = document.querySelector('.bio-body');
if (bodyEl && Array.isArray(bio.paragraphs) && bio.paragraphs.length) {
const cta = bodyEl.querySelector('.bio-aside-cta');
// Replace paragraphs but keep the trailing CTA paragraph
[...bodyEl.querySelectorAll('p:not(.bio-aside-cta)')].forEach(p => p.remove());
bio.paragraphs.forEach((html, idx) => {
const p = document.createElement('p');
if (idx === 0) p.classList.add('lead');
p.innerHTML = (idx === 0 ? `<span class="dropcap">${(html.replace(/<[^>]+>/g,'').trim()[0] || '')}</span>` + stripFirstChar(html) : html);
bodyEl.insertBefore(p, cta || null);
});
}
}
function stripFirstChar(html) {
// Remove the first visible character (we put it in dropcap)
let i = 0;
while (i < html.length) {
if (html[i] === '<') {
const close = html.indexOf('>', i);
if (close === -1) break;
i = close + 1;
} else if (/\s/.test(html[i])) {
i++;
} else {
return html.slice(0, i) + html.slice(i + 1);
}
}
return html;
}
renderBio();
// ============================================================
// 10. Render exhibitions
// ============================================================
function renderExhibitions() {
const list = document.querySelector('.exhib-list');
if (!list || !Array.isArray(data.exhibitions) || data.exhibitions.length === 0) return;
list.innerHTML = '';
data.exhibitions.forEach((ex, i) => {
const li = document.createElement('li');
li.className = 'exhib-item';
const num = String(i + 1).padStart(2, '0');
const nameHtml = ex.url
? `<a href="${escapeAttr(ex.url)}" target="_blank" rel="noopener">${escapeHtml(ex.name)} <span class="link-arrow">↗</span></a>`
: escapeHtml(ex.name);
li.innerHTML = `
<span class="ex-marker">${num}</span>
<div class="ex-content">
<h3 class="ex-name">${nameHtml}</h3>
<p class="ex-meta">${escapeHtml(ex.location || '')}</p>
</div>
<span class="ex-rule" aria-hidden="true"></span>`;
list.appendChild(li);
});
}
renderExhibitions();
// ============================================================
// 11. Render contact links
// ============================================================
function renderContact() {
const c = data.contact || {};
const sel = (s) => document.querySelectorAll(s);
if (c.email) {
sel('a[href^="mailto:"]').forEach(a => {
a.href = 'mailto:' + c.email;
const v = a.querySelector('.cc-value');
if (v) v.textContent = c.email;
});
}
if (c.etsy) {
sel('a[href*="etsy.com"]').forEach(a => a.href = c.etsy);
}
if (c.gallery) {
sel('a[href*="visualexpansiongallery"]').forEach(a => a.href = c.gallery);
}
}
renderContact();
// ============================================================
// 12. Fetch live data and re-render if it differs
// ============================================================
fetch('/api/data', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(remote => {
if (!remote || typeof remote !== 'object') return;
data = remote;
works = remote.works || works;
// Re-render everything
renderHero();
renderBio();
renderExhibitions();
renderContact();
renderGallery();
})
.catch(() => { /* fallback already rendered */ });
})();