|
1 | 1 | (function registerFeatured(){ |
2 | 2 | const namespace = window.features = window.features || {}; |
3 | 3 |
|
| 4 | + // helper: determine if a hex color is light |
| 5 | + function hexToRgb(hex) { |
| 6 | + if (!hex) return null; |
| 7 | + hex = hex.replace('#',''); |
| 8 | + if (hex.length === 3) hex = hex.split('').map(c=>c+c).join(''); |
| 9 | + const int = parseInt(hex,16); |
| 10 | + return { r: (int>>16)&255, g: (int>>8)&255, b: int&255 }; |
| 11 | + } |
| 12 | + function luminance(r,g,b){ |
| 13 | + const a = [r,g,b].map(v=>{ |
| 14 | + v/=255; return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4); |
| 15 | + }); |
| 16 | + return 0.2126*a[0] + 0.7152*a[1] + 0.0722*a[2]; |
| 17 | + } |
| 18 | + function isLight(hex){ |
| 19 | + const rgb = hexToRgb(hex); |
| 20 | + if (!rgb) return false; |
| 21 | + return luminance(rgb.r,rgb.g,rgb.b) > 0.6; // threshold tuned for readability |
| 22 | + } |
| 23 | + |
| 24 | + // Cache for extracted colors per URL/name to avoid repeated work |
| 25 | + const _colorCache = new Map(); |
| 26 | + function rgbToHex(r,g,b){ |
| 27 | + return '#'+[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join(''); |
| 28 | + } |
| 29 | + // Extract a simple average color from an image URL (uses canvas). Returns hex or null. |
| 30 | + function extractDominantColor(url){ |
| 31 | + if (!url) return Promise.resolve(null); |
| 32 | + if (_colorCache.has(url)) return Promise.resolve(_colorCache.get(url)); |
| 33 | + return new Promise(resolve => { |
| 34 | + try { |
| 35 | + const img = new Image(); |
| 36 | + img.crossOrigin = 'Anonymous'; |
| 37 | + img.src = url; |
| 38 | + img.onload = () => { |
| 39 | + try { |
| 40 | + const size = 32; |
| 41 | + const canvas = document.createElement('canvas'); |
| 42 | + canvas.width = size; canvas.height = size; |
| 43 | + const ctx = canvas.getContext('2d'); |
| 44 | + ctx.drawImage(img, 0, 0, size, size); |
| 45 | + const data = ctx.getImageData(0,0,size,size).data; |
| 46 | + let r=0,g=0,b=0,count=0; |
| 47 | + for (let i=0;i<data.length;i+=4){ |
| 48 | + const alpha = data[i+3]; |
| 49 | + if (alpha < 32) continue; // skip mostly transparent |
| 50 | + r += data[i]; g += data[i+1]; b += data[i+2]; count++; |
| 51 | + } |
| 52 | + if (!count) { _colorCache.set(url,null); resolve(null); return; } |
| 53 | + r = Math.round(r/count); g = Math.round(g/count); b = Math.round(b/count); |
| 54 | + const hex = rgbToHex(r,g,b); |
| 55 | + _colorCache.set(url, hex); |
| 56 | + resolve(hex); |
| 57 | + } catch(e){ _colorCache.set(url,null); resolve(null); } |
| 58 | + }; |
| 59 | + img.onerror = () => { _colorCache.set(url,null); resolve(null); }; |
| 60 | + } catch(e){ resolve(null); } |
| 61 | + }); |
| 62 | + } |
| 63 | + |
4 | 64 | function renderItem(item, container) { |
5 | 65 | const html = ` |
6 | 66 | <div class="featured-item" role="group" aria-label="${item.title}"> |
7 | | - <div class="featured-visual" style="background:${item.color || 'var(--primary)'}"> |
| 67 | + <div class="featured-visual"> |
8 | 68 | <img class="featured-icon" src="${getIconUrl(item.name||'')}" alt="${item.title}" onerror="this.onerror=null;this.src='https://raw.githubusercontent.com/Portable-Linux-Apps/Portable-Linux-Apps.github.io/main/icons/blank.png'"/> |
9 | 69 | </div> |
10 | 70 | <div class="featured-body"> |
|
18 | 78 | function init(options = {}) { |
19 | 79 | const container = options.container || document.getElementById('featuredBanner'); |
20 | 80 | const state = options.state || {}; |
21 | | - const items = options.items || (window?.require ? (window.require('./config/featured') || []) : []); |
| 81 | + const DEFAULT_ITEMS = [ |
| 82 | + { name: 'code', title: 'Visual Studio Code', desc: 'A lightweight but powerful source code editor.', color: '#007ACC' }, |
| 83 | + { name: 'vlc', title: 'VLC', desc: 'A free and open source cross-platform multimedia player.', color: '#E02525' }, |
| 84 | + { name: 'jellyfin', title: 'Jellyfin', desc: 'A personal media server that puts you in control.', color: '#F58A25' } |
| 85 | + ]; |
| 86 | + // allow passing a static featured config; else try require; else default |
| 87 | + const featuredConfig = (Array.isArray(options.featuredConfig) && options.featuredConfig.length) |
| 88 | + ? options.featuredConfig |
| 89 | + : (window?.require ? (function(){ try { return window.require('./config/featured') || []; } catch(_) { return []; } })() : []); |
| 90 | + let items = []; |
| 91 | + // initialize empty to avoid flash of static items; updateFromState will later populate |
22 | 92 | if (!container) return null; |
23 | 93 | container.innerHTML = ` |
24 | 94 | <div class="featured-inner"> |
25 | | - <button class="featured-prev" aria-label="Previous">◀</button> |
26 | | - <div class="featured-slot"></div> |
27 | | - <button class="featured-next" aria-label="Next">▶</button> |
| 95 | + <div class="featured-controls"> |
| 96 | + <button class="featured-prev" aria-label="Previous">◀</button> |
| 97 | + <div class="featured-slot"></div> |
| 98 | + <button class="featured-next" aria-label="Next">▶</button> |
| 99 | + </div> |
28 | 100 | <div class="featured-dots" aria-hidden="true"></div> |
29 | 101 | </div>`; |
30 | 102 |
|
|
46 | 118 | if (!items || !items.length) { slot.innerHTML = ''; return; } |
47 | 119 | idx = (index + items.length) % items.length; |
48 | 120 | renderItem(items[idx], slot); |
| 121 | + const featuredInnerEl = container.querySelector('.featured-inner'); |
| 122 | + const item = items[idx]; |
| 123 | + // apply color to entire banner if provided, otherwise try to extract from the icon |
| 124 | + async function applyColorHex(hex){ |
| 125 | + if (hex) { |
| 126 | + featuredInnerEl.style.background = `linear-gradient(90deg, rgba(0,0,0,0.14), rgba(0,0,0,0.10)), linear-gradient(90deg, ${hex}44, ${hex}33)`; |
| 127 | + featuredInnerEl.classList.add('has-color'); |
| 128 | + if (isLight(hex)) { featuredInnerEl.classList.add('light-text'); featuredInnerEl.classList.remove('dark-text'); } |
| 129 | + else { featuredInnerEl.classList.add('dark-text'); featuredInnerEl.classList.remove('light-text'); } |
| 130 | + } else { |
| 131 | + featuredInnerEl.style.background = ''; |
| 132 | + featuredInnerEl.classList.remove('has-color','light-text','dark-text'); |
| 133 | + } |
| 134 | + } |
| 135 | + if (item && item.color) { |
| 136 | + applyColorHex(item.color); |
| 137 | + } else { |
| 138 | + // try extracting from icon; do not block render |
| 139 | + const iconUrl = getIconUrl(item.name||''); |
| 140 | + applyColorHex(null); // clear first |
| 141 | + extractDominantColor(iconUrl).then(hex => { |
| 142 | + if (!hex) return; |
| 143 | + applyColorHex(hex); |
| 144 | + }).catch(()=>{}); |
| 145 | + } |
49 | 146 | const btns = dots.querySelectorAll('.dot'); |
50 | 147 | btns.forEach((b,i)=> b.classList.toggle('active', i===idx)); |
51 | 148 | // add click handler on slot to open details |
52 | | - const item = items[idx]; |
53 | 149 | const itemEl = slot.querySelector('.featured-item'); |
54 | 150 | if (itemEl) { |
55 | 151 | itemEl.style.cursor = 'pointer'; |
|
58 | 154 | } |
59 | 155 |
|
60 | 156 | function goTo(i) { show(i); resetTimer(); } |
61 | | - function nextItem() { show(idx+1); } |
62 | | - function prevItem() { show(idx-1); } |
| 157 | + function nextItem() { |
| 158 | + // if we're at the last item, pick a new random subset and start from the beginning |
| 159 | + if (!items || items.length === 0) return; |
| 160 | + if (idx === items.length - 1) { |
| 161 | + const newItems = computeItemsFromState(); |
| 162 | + if (Array.isArray(newItems) && newItems.length) { |
| 163 | + // remember current page in history before replacing |
| 164 | + pushHistory(items); |
| 165 | + updateItems(newItems); |
| 166 | + return; // updateItems calls show(0) |
| 167 | + } |
| 168 | + } |
| 169 | + show(idx+1); |
| 170 | + } |
| 171 | + function prevItem() { |
| 172 | + // if at first item, try restoring previous page from history |
| 173 | + if (idx === 0) { |
| 174 | + const prev = popHistory(); |
| 175 | + if (Array.isArray(prev) && prev.length) { |
| 176 | + // restore previous items and show the last entry |
| 177 | + updateItems(prev, { resetIndex: true, index: prev.length - 1 }); |
| 178 | + return; |
| 179 | + } |
| 180 | + } |
| 181 | + show(idx-1); |
| 182 | + } |
63 | 183 | function resetTimer() { if (timer) { clearInterval(timer); } timer = setInterval(nextItem, 6000); } |
64 | 184 |
|
65 | 185 | prev.addEventListener('click', () => { prevItem(); resetTimer(); }); |
|
75 | 195 | if (e.key === 'ArrowRight') { nextItem(); resetTimer(); } |
76 | 196 | }); |
77 | 197 |
|
78 | | - return Object.freeze({ show, goTo, destroy() { clearInterval(timer); } }); |
| 198 | + // history stack to allow restoring previous pages (max HISTORY_LIMIT) |
| 199 | + const HISTORY_LIMIT = 10; |
| 200 | + let history = []; |
| 201 | + function pushHistory(arr){ |
| 202 | + try{ |
| 203 | + if (!Array.isArray(arr) || !arr.length) return; |
| 204 | + history.push(arr.slice()); |
| 205 | + if (history.length > HISTORY_LIMIT) history.shift(); |
| 206 | + }catch(e){} |
| 207 | + } |
| 208 | + function popHistory(){ |
| 209 | + try{ return history.length ? history.pop() : null; }catch(e){ return null; } |
| 210 | + } |
| 211 | + |
| 212 | + function updateItems(newItems, opts = { resetIndex: true, index: 0 }) { |
| 213 | + items = (Array.isArray(newItems) && newItems.length) ? newItems : DEFAULT_ITEMS; |
| 214 | + updateDots(); |
| 215 | + if (opts && opts.resetIndex) show(opts.index || 0); |
| 216 | + resetTimer(); |
| 217 | + } |
| 218 | + |
| 219 | + function computeItemsFromState() { |
| 220 | + try { |
| 221 | + const active = (state && (state.categoryOverride && (state.categoryOverride.norm || state.categoryOverride.name || state.categoryOverride.category))) || (state && state.activeCategory) || 'all'; |
| 222 | + // Build candidates |
| 223 | + let candidates = []; |
| 224 | + if (active === 'all') { |
| 225 | + candidates = (Array.isArray(state.filtered) && state.filtered.length) ? state.filtered : (state.allApps || []); |
| 226 | + } else if (active === 'installed') { |
| 227 | + candidates = (state.allApps || []).filter(a => a && a.installed); |
| 228 | + } else { |
| 229 | + const norm = s => (s && typeof s === 'string') ? s.trim().toLowerCase() : ''; |
| 230 | + candidates = (state.allApps || []).filter(a => a && norm(a.category) === norm(active)); |
| 231 | + } |
| 232 | + const source = (Array.isArray(state.filtered) && state.filtered.length) ? state.filtered : candidates; |
| 233 | + const arr = (source || []).filter(app => app && app.name); |
| 234 | + // shuffle and pick subset |
| 235 | + const a = arr.slice(); |
| 236 | + for (let i = a.length - 1; i > 0; i--) { |
| 237 | + const j = Math.floor(Math.random() * (i + 1)); const tmp = a[i]; a[i] = a[j]; a[j] = tmp; |
| 238 | + } |
| 239 | + const picked = a.slice(0, 5); |
| 240 | + const mapped = picked.map(app => ({ name: app.name, title: app.name ? (app.name.charAt(0).toUpperCase()+app.name.slice(1)) : (app?.title||app?.name||''), desc: app?.desc || app?.short || '', color: (featuredConfig.find(f => f.name === app.name) || {}).color || '' })); |
| 241 | + // fallback: only if apps are loaded return static featured |
| 242 | + if (mapped && mapped.length) return mapped; |
| 243 | + if (Array.isArray(state.allApps) && state.allApps.length > 0) return (featuredConfig.length ? featuredConfig : DEFAULT_ITEMS); |
| 244 | + return []; |
| 245 | + } catch (e) { return []; } |
| 246 | + } |
| 247 | + |
| 248 | + function updateFromState() { |
| 249 | + try { |
| 250 | + // clear history when state changes (new category) to avoid mixing contexts |
| 251 | + history = []; |
| 252 | + const newItems = computeItemsFromState(); |
| 253 | + updateItems(newItems); |
| 254 | + // decide visibility: show if items and category allowed and not details and no active search |
| 255 | + const bannerEl = container; |
| 256 | + if (!bannerEl) return newItems; |
| 257 | + const searchEl = document.getElementById('searchInput'); |
| 258 | + const q = (searchEl && String(searchEl.value || '').trim()) || ''; |
| 259 | + // determine if search is active (either search feature reports searchMode OR input currently focused) |
| 260 | + let searchActive = false; |
| 261 | + try { |
| 262 | + const searchApi = window.features?.search; |
| 263 | + const stateInfo = typeof searchApi?.getSearchState === 'function' ? searchApi.getSearchState() : null; |
| 264 | + if (stateInfo && stateInfo.searchMode) searchActive = true; |
| 265 | + } catch (e) {} |
| 266 | + if (searchEl && document.activeElement === searchEl) searchActive = true; |
| 267 | + const isDetails = document.body.classList.contains('details-mode'); |
| 268 | + const isAllowedCategory = !!(state && (state.activeCategory === 'all' || state.categoryOverride)); |
| 269 | + // Only hide when BOTH: search is active AND there is non-empty query text. |
| 270 | + const hideBecauseOfSearch = searchActive && q.length > 0; |
| 271 | + bannerEl.hidden = !(newItems && newItems.length && isAllowedCategory && !isDetails && !hideBecauseOfSearch); |
| 272 | + return newItems; |
| 273 | + } catch (e) { return []; } |
| 274 | + } |
| 275 | + |
| 276 | + return Object.freeze({ show, goTo, updateItems, updateFromState, destroy() { clearInterval(timer); } }); |
79 | 277 | } |
80 | 278 |
|
81 | 279 | namespace.featured = Object.freeze({ init }); |
|
0 commit comments