|
1 | 1 | <!DOCTYPE html> |
2 | 2 | <html lang="en"> |
3 | 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> |
| 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 | + |
| 9 | + <!-- Fonts --> |
| 10 | + <link rel="preconnect" href="https://fonts.googleapis.com"> |
| 11 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 12 | + <link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@100..900&display=swap" rel="stylesheet"> |
| 13 | + |
| 14 | + <!-- Styles --> |
| 15 | + <link rel="stylesheet" href="/assets/gallery.css"> |
| 16 | + <style> |
| 17 | + body { |
| 18 | + font-family: "Albert Sans", system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; |
| 19 | + } |
| 20 | + a.back-link { |
| 21 | + position: fixed; |
| 22 | + top: 10px; |
| 23 | + left: 10px; |
| 24 | + background: white; |
| 25 | + color: #000; |
| 26 | + padding: 8px 12px; |
| 27 | + border: 1px solid #ccc; |
| 28 | + border-radius: 5px; |
| 29 | + text-decoration: none; |
| 30 | + z-index: 1000; |
| 31 | + } |
| 32 | + .filter-controls { |
| 33 | + display: flex; |
| 34 | + gap: 8px; |
| 35 | + flex-wrap: wrap; |
| 36 | + align-items: center; |
| 37 | + max-width: 1100px; |
| 38 | + margin: 60px auto 12px; |
| 39 | + padding: 0 12px; |
| 40 | + } |
| 41 | + .filter-controls button { |
| 42 | + padding: 6px 12px; |
| 43 | + border: 1px solid #ccc; |
| 44 | + background: #fff; |
| 45 | + border-radius: 6px; |
| 46 | + cursor: pointer; |
| 47 | + } |
| 48 | + .filter-controls button.active { |
| 49 | + background: #111; |
| 50 | + color: #fff; |
| 51 | + border-color: #111; |
| 52 | + } |
| 53 | + .gallery[data-hidden="true"] { |
| 54 | + display: none; |
| 55 | + } |
| 56 | + .desc small { |
| 57 | + color: #666; |
| 58 | + font-weight: 500; |
| 59 | + margin-left: 6px; |
| 60 | + } |
| 61 | + </style> |
20 | 62 | </head> |
21 | 63 | <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"> |
| 64 | + <!-- Back button --> |
| 65 | + <a href="/" class="back-link">←</a> |
| 66 | + |
| 67 | + <!-- Filters --> |
| 68 | + <div class="filter-controls" aria-label="Filter items"> |
45 | 69 | <button type="button" data-filter="all" class="active">All</button> |
46 | 70 | <button type="button" data-filter="movie">Movies</button> |
47 | 71 | <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){ |
| 72 | + </div> |
| 73 | + |
| 74 | + <!-- Gallery --> |
| 75 | + <div class="gallery-container"> |
| 76 | + <div class="gallery" data-type="movie"> |
| 77 | + <a href="https://www.imdb.com/title/tt0468569/" target="_blank"> |
| 78 | + <img alt="The Dark Knight poster" loading="lazy"> |
| 79 | + </a> |
| 80 | + <div class="desc">The Dark Knight <small>Movie</small></div> |
| 81 | + </div> |
| 82 | + |
| 83 | + <div class="gallery" data-type="book" data-title="The Great Gatsby" data-isbn="0743273567"> |
| 84 | + <a href="https://www.goodreads.com/book/show/4671.The_Great_Gatsby" target="_blank"> |
| 85 | + <img alt="The Great Gatsby cover" loading="lazy"> |
| 86 | + </a> |
| 87 | + <div class="desc">The Great Gatsby <small>Book</small></div> |
| 88 | + </div> |
| 89 | + </div> |
| 90 | + |
| 91 | + <!-- Scripts --> |
| 92 | + <script> |
| 93 | + (function(){ |
| 94 | + const qs = (sel, root=document) => root.querySelector(sel); |
| 95 | + const qsa = (sel, root=document) => [...root.querySelectorAll(sel)]; |
| 96 | + |
| 97 | + function setActiveFilter(btn) { |
| 98 | + qsa('.filter-controls button').forEach(b => b.classList.toggle('active', b === btn)); |
| 99 | + } |
| 100 | + |
| 101 | + function applyFilter(type) { |
81 | 102 | 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'); } |
| 103 | + const match = type === 'all' || card.dataset.type === type; |
| 104 | + card.dataset.hidden = match ? 'false' : 'true'; |
| 105 | + if (match) card.removeAttribute('data-hidden'); |
86 | 106 | }); |
87 | | - } |
| 107 | + } |
88 | 108 |
|
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 | | - } |
| 109 | + function extractImdbId(url) { |
| 110 | + const match = String(url||'').match(/imdb\.com\/title\/(tt\d{5,9})/i); |
| 111 | + return match ? match[1] : null; |
| 112 | + } |
93 | 113 |
|
94 | | - async function fetchImdbPoster(imdbId){ |
95 | | - if(!imdbId) return null; |
96 | | - const cacheKey = `thumb:imdb:${imdbId}`; |
| 114 | + async function fetchImdbPoster(id) { |
| 115 | + if (!id) return null; |
| 116 | + const cacheKey = `thumb:imdb:${id}`; |
97 | 117 | 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 | | - } |
| 118 | + if (cached) return cached; |
| 119 | + |
| 120 | + const key = window.OMDB_API_KEY?.trim(); |
| 121 | + if (!key) return null; |
| 122 | + |
| 123 | + try { |
| 124 | + const res = await fetch(`https://www.omdbapi.com/?i=${id}&apikey=${key}`); |
| 125 | + const data = await res.json(); |
| 126 | + if (data.Poster && data.Poster !== 'N/A') { |
| 127 | + localStorage.setItem(cacheKey, data.Poster); |
| 128 | + return data.Poster; |
| 129 | + } |
| 130 | + } catch {} |
117 | 131 | 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 | + } |
| 133 | + |
| 134 | + function parseGoodreadsTitle(url) { |
| 135 | + try { |
| 136 | + const parts = new URL(url).pathname.split('/').filter(Boolean); |
| 137 | + const idx = parts.indexOf('show'); |
| 138 | + if (idx >= 0 && parts[idx+1]) { |
| 139 | + const slug = parts[idx+1]; |
| 140 | + return decodeURIComponent(slug.split('.').slice(1).join('.').replace(/_/g, ' ')); |
| 141 | + } |
| 142 | + } catch {} |
132 | 143 | return null; |
133 | | - } |
| 144 | + } |
134 | 145 |
|
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); |
| 146 | + async function fetchBookCover({ title, isbn }) { |
| 147 | + if (isbn) { |
| 148 | + const url = `https://covers.openlibrary.org/b/isbn/${isbn}-L.jpg?default=false`; |
| 149 | + if ((await fetch(url, { method: 'HEAD' })).ok) return url; |
155 | 150 | } |
| 151 | + if (!title) return null; |
| 152 | + try { |
| 153 | + const res = await fetch(`https://openlibrary.org/search.json?title=${encodeURIComponent(title)}&limit=1`); |
| 154 | + const doc = (await res.json()).docs?.[0]; |
| 155 | + if (doc?.cover_i) return `https://covers.openlibrary.org/b/id/${doc.cover_i}-L.jpg`; |
| 156 | + } catch {} |
156 | 157 | 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 | | - } |
| 158 | + } |
| 159 | + |
| 160 | + function fallbackImage(type) { |
| 161 | + const svg = type === 'book' |
| 162 | + ? `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="100%" height="100%" fill="#f3f3f3"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#999" font-family="sans-serif" font-size="36">Book cover</text></svg>` |
| 163 | + : `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1800"><rect width="100%" height="100%" fill="#f3f3f3"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#999" font-family="sans-serif" font-size="36">Movie poster</text></svg>`; |
| 164 | + return `data:image/svg+xml;utf8,${svg}`; |
| 165 | + } |
| 166 | + |
| 167 | + async function populateThumbnails() { |
| 168 | + for (const card of qsa('.gallery-container .gallery')) { |
| 169 | + const type = card.dataset.type; |
| 170 | + const link = qs('a', card); |
| 171 | + const img = qs('img', card); |
| 172 | + let src = ''; |
| 173 | + |
| 174 | + try { |
| 175 | + if (type === 'movie') { |
| 176 | + src = await fetchImdbPoster(card.dataset.imdbId || extractImdbId(link?.href)) || ''; |
| 177 | + } else if (type === 'book') { |
| 178 | + src = await fetchBookCover({ |
| 179 | + isbn: card.dataset.isbn, |
| 180 | + title: card.dataset.title || parseGoodreadsTitle(link?.href) |
| 181 | + }) || ''; |
| 182 | + } |
| 183 | + } catch {} |
165 | 184 |
|
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); |
| 185 | + img.src = src || fallbackImage(type); |
187 | 186 | } |
188 | | - } |
| 187 | + } |
189 | 188 |
|
190 | | - function initFilters(){ |
| 189 | + function initFilters() { |
191 | 190 | qsa('.filter-controls button').forEach(btn => { |
192 | | - btn.addEventListener('click', () => { |
193 | | - setActiveFilter(btn); |
194 | | - applyFilter(btn.dataset.filter); |
195 | | - }); |
| 191 | + btn.addEventListener('click', () => { |
| 192 | + setActiveFilter(btn); |
| 193 | + applyFilter(btn.dataset.filter); |
| 194 | + }); |
196 | 195 | }); |
197 | | - } |
| 196 | + } |
198 | 197 |
|
199 | | - // init |
200 | | - document.addEventListener('DOMContentLoaded', () => { |
| 198 | + document.addEventListener('DOMContentLoaded', () => { |
201 | 199 | initFilters(); |
202 | 200 | populateThumbnails(); |
203 | | - }); |
204 | | -})(); |
205 | | -</script> |
| 201 | + }); |
| 202 | + })(); |
| 203 | + </script> |
206 | 204 | </body> |
207 | 205 | </html> |
0 commit comments