|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | +<meta name="robots" content="index, follow"> |
| 7 | +<title>Mo Shakiba | Talks</title> |
| 8 | +<link rel="preconnect" href="https://fonts.googleapis.com"> |
| 9 | +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 10 | +<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> |
| 11 | +<link rel="stylesheet" href="/assets/gallery.css"> |
| 12 | +<style> |
| 13 | +/* Lightweight UI for filters and safe defaults */ |
| 14 | +.filter-controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; max-width:1100px; margin:60px auto 12px; padding:0 12px; } |
| 15 | +.filter-controls button { padding:6px 12px; border:1px solid #ccc; background:#fff; border-radius:6px; cursor:pointer; font-family: "Albert Sans", system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } |
| 16 | +.filter-controls button.active { background:#111; color:#fff; border-color:#111; } |
| 17 | +.gallery[data-hidden="true"] { display:none; } |
| 18 | +.desc small { color:#666; font-weight:500; margin-left:6px; } |
| 19 | +</style> |
| 20 | +</head> |
| 21 | +<body> |
| 22 | +<a href="/" style=" |
| 23 | + position: fixed; |
| 24 | + top: 10px; |
| 25 | + left: 10px; |
| 26 | + background-color: white; |
| 27 | + color: #000; |
| 28 | + padding: 8px 12px; |
| 29 | + border: 1px solid #ccc; |
| 30 | + border-radius: 5px; |
| 31 | + text-decoration: none; |
| 32 | + font-family: sans-serif; |
| 33 | + z-index: 1000; |
| 34 | +"> |
| 35 | + ← |
| 36 | +</a> |
| 37 | + |
| 38 | +<!-- Optional: set your OMDb API key for higher quality IMDb posters |
| 39 | +<script> |
| 40 | + // window.OMDB_API_KEY = "YOUR_OMDB_KEY"; |
| 41 | +</script> |
| 42 | +--> |
| 43 | + |
| 44 | +<div class="filter-controls" aria-label="Filter items"> |
| 45 | + <button type="button" data-filter="all" class="active">All</button> |
| 46 | + <button type="button" data-filter="movie">Movies</button> |
| 47 | + <button type="button" data-filter="book">Books</button> |
| 48 | +</div> |
| 49 | + |
| 50 | +<div class="gallery-container"> |
| 51 | + |
| 52 | + <!-- Usage examples (replace with your own items): --> |
| 53 | + <!-- IMDb movie: set data-type="movie" and link to an IMDb title page; poster is auto-fetched. --> |
| 54 | + <div class="gallery" data-type="movie"> |
| 55 | + <a target="_blank" href="https://www.imdb.com/title/tt0468569/"> |
| 56 | + <img src="" alt="The Dark Knight poster" loading="lazy"> |
| 57 | + </a> |
| 58 | + <div class="desc">The Dark Knight <small>Movie</small></div> |
| 59 | + </div> |
| 60 | + |
| 61 | + <!-- Goodreads book: set data-type="book" and link to a Goodreads book page. Optionally add data-title or data-isbn for better matches. --> |
| 62 | + <div class="gallery" data-type="book" data-title="The Great Gatsby" data-isbn="0743273567"> |
| 63 | + <a target="_blank" href="https://www.goodreads.com/book/show/4671.The_Great_Gatsby"> |
| 64 | + <img src="" alt="The Great Gatsby cover" loading="lazy"> |
| 65 | + </a> |
| 66 | + <div class="desc">The Great Gatsby <small>Book</small></div> |
| 67 | + </div> |
| 68 | + |
| 69 | +</div> |
| 70 | + |
| 71 | +<script> |
| 72 | +(function(){ |
| 73 | + const qs = (s, r=document) => r.querySelector(s); |
| 74 | + const qsa = (s, r=document) => Array.from(r.querySelectorAll(s)); |
| 75 | + |
| 76 | + function setActiveFilter(btn){ |
| 77 | + qsa('.filter-controls button').forEach(b=>b.classList.toggle('active', b===btn)); |
| 78 | + } |
| 79 | + |
| 80 | + function applyFilter(type){ |
| 81 | + qsa('.gallery-container .gallery').forEach(card => { |
| 82 | + const t = card.dataset.type; |
| 83 | + const show = (type === 'all') ? true : (t === type); |
| 84 | + card.dataset.hidden = show ? 'false' : 'true'; |
| 85 | + if(show){ card.removeAttribute('data-hidden'); } else { card.setAttribute('data-hidden', 'true'); } |
| 86 | + }); |
| 87 | + } |
| 88 | + |
| 89 | + function extractImdbId(url){ |
| 90 | + const m = String(url||'').match(/imdb\.com\/title\/(tt\d{5,9})/i); |
| 91 | + return m ? m[1] : null; |
| 92 | + } |
| 93 | + |
| 94 | + async function fetchImdbPoster(imdbId){ |
| 95 | + if(!imdbId) return null; |
| 96 | + const cacheKey = `thumb:imdb:${imdbId}`; |
| 97 | + const cached = localStorage.getItem(cacheKey); |
| 98 | + if(cached) return cached; |
| 99 | + |
| 100 | + const key = (typeof window.OMDB_API_KEY === 'string' && window.OMDB_API_KEY.trim()) ? window.OMDB_API_KEY.trim() : null; |
| 101 | + if(!key){ |
| 102 | + console.warn('[gallery] No OMDb API key set; skipping IMDb poster fetch for', imdbId); |
| 103 | + return null; |
| 104 | + } |
| 105 | + try{ |
| 106 | + const url = `https://www.omdbapi.com/?i=${encodeURIComponent(imdbId)}&plot=short&r=json&apikey=${encodeURIComponent(key)}`; |
| 107 | + const res = await fetch(url); |
| 108 | + if(!res.ok) throw new Error('HTTP '+res.status); |
| 109 | + const data = await res.json(); |
| 110 | + if(data && data.Poster && data.Poster !== 'N/A'){ |
| 111 | + localStorage.setItem(cacheKey, data.Poster); |
| 112 | + return data.Poster; |
| 113 | + } |
| 114 | + }catch(err){ |
| 115 | + console.warn('[gallery] OMDb fetch failed', err); |
| 116 | + } |
| 117 | + return null; |
| 118 | + } |
| 119 | + |
| 120 | + function parseGoodreadsTitleFromUrl(url){ |
| 121 | + try{ |
| 122 | + const u = new URL(url); |
| 123 | + const parts = u.pathname.split('/').filter(Boolean); |
| 124 | + const showIdx = parts.findIndex(p=>p.toLowerCase()==='show'); |
| 125 | + if(showIdx >= 0 && parts[showIdx+1]){ |
| 126 | + const slug = parts[showIdx+1]; |
| 127 | + const dot = slug.indexOf('.') |
| 128 | + const namePart = dot>=0 ? slug.slice(dot+1) : slug; |
| 129 | + return decodeURIComponent(namePart.replace(/_/g,' ')); |
| 130 | + } |
| 131 | + }catch(_){ /* ignore */ } |
| 132 | + return null; |
| 133 | + } |
| 134 | + |
| 135 | + async function fetchOpenLibraryCover({ title, isbn }){ |
| 136 | + // Prefer ISBN direct cover if provided |
| 137 | + if(isbn){ |
| 138 | + const isbnUrl = `https://covers.openlibrary.org/b/isbn/${encodeURIComponent(isbn)}-L.jpg?default=false`; |
| 139 | + try{ |
| 140 | + const head = await fetch(isbnUrl, { method: 'HEAD' }); |
| 141 | + if(head.ok) return isbnUrl; |
| 142 | + }catch(_){/* ignore */} |
| 143 | + } |
| 144 | + if(!title) return null; |
| 145 | + try{ |
| 146 | + const res = await fetch(`https://openlibrary.org/search.json?title=${encodeURIComponent(title)}&limit=1`); |
| 147 | + if(!res.ok) throw new Error('HTTP '+res.status); |
| 148 | + const data = await res.json(); |
| 149 | + const doc = data && data.docs && data.docs[0]; |
| 150 | + if(doc && doc.cover_i){ |
| 151 | + return `https://covers.openlibrary.org/b/id/${doc.cover_i}-L.jpg`; |
| 152 | + } |
| 153 | + }catch(err){ |
| 154 | + console.warn('[gallery] OpenLibrary search failed', err); |
| 155 | + } |
| 156 | + return null; |
| 157 | + } |
| 158 | + |
| 159 | + function fallbackImage(type){ |
| 160 | + // neutral placeholders (no external tracking) |
| 161 | + return type === 'book' |
| 162 | + ? 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="100%" height="100%" fill="%23f3f3f3"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%23999" font-family="sans-serif" font-size="36">Book cover</text></svg>' |
| 163 | + : 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1800"><rect width="100%" height="100%" fill="%23f3f3f3"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%23999" font-family="sans-serif" font-size="36">Movie poster</text></svg>'; |
| 164 | + } |
| 165 | + |
| 166 | + async function populateThumbnails(){ |
| 167 | + const cards = qsa('.gallery-container .gallery[data-type]'); |
| 168 | + for(const card of cards){ |
| 169 | + const type = card.dataset.type; |
| 170 | + const linkEl = qs('a', card); |
| 171 | + const imgEl = qs('img', card) || document.createElement('img'); |
| 172 | + if(!imgEl.parentElement){ linkEl.prepend(imgEl); } |
| 173 | + let src = ''; |
| 174 | + try{ |
| 175 | + if(type === 'movie'){ |
| 176 | + const imdbId = card.dataset.imdbId || extractImdbId(linkEl?.href); |
| 177 | + src = await fetchImdbPoster(imdbId) || ''; |
| 178 | + } else if(type === 'book'){ |
| 179 | + const isbn = card.dataset.isbn; |
| 180 | + const title = card.dataset.title || parseGoodreadsTitleFromUrl(linkEl?.href); |
| 181 | + src = await fetchOpenLibraryCover({ title, isbn }) || ''; |
| 182 | + } |
| 183 | + }catch(err){ console.warn('[gallery] thumbnail generation error', err); } |
| 184 | + imgEl.loading = imgEl.loading || 'lazy'; |
| 185 | + imgEl.alt = imgEl.alt || (type==='book' ? 'Book cover' : type==='movie' ? 'Movie poster' : 'Thumbnail'); |
| 186 | + imgEl.src = src || imgEl.src || fallbackImage(type); |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + function initFilters(){ |
| 191 | + qsa('.filter-controls button').forEach(btn => { |
| 192 | + btn.addEventListener('click', () => { |
| 193 | + setActiveFilter(btn); |
| 194 | + applyFilter(btn.dataset.filter); |
| 195 | + }); |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + // init |
| 200 | + document.addEventListener('DOMContentLoaded', () => { |
| 201 | + initFilters(); |
| 202 | + populateThumbnails(); |
| 203 | + }); |
| 204 | +})(); |
| 205 | +</script> |
| 206 | +</body> |
| 207 | +</html> |
0 commit comments