Skip to content

Commit ffa57e2

Browse files
committed
feat(featured): bannière dynamique (extraction couleur, historique, shuffle) + ajustements UI/CSS
1 parent 0e45319 commit ffa57e2

File tree

6 files changed

+334
-27
lines changed

6 files changed

+334
-27
lines changed

.github/workflows/appimage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ concurrency:
55
on:
66
push:
77
branches:
8-
- build
8+
- main
99
schedule:
1010
- cron: "0 7 1/21 * *" # We default to rebuilding every 21 days, change this to your liking
1111
workflow_dispatch: {}

src/renderer/features/categories/dropdown.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@
182182
apps: detailedApps,
183183
toastMessage: `Catégorie "${name}" : ${filteredApps.length} apps`
184184
});
185+
// notify other parts (renderer) that a custom category was activated so they can refresh UI such as featured
186+
try { document.dispatchEvent(new CustomEvent('category.override', { detail: { name, count: detailedApps.length } })); } catch(_) {}
185187
}, iconMap);
186188
categoriesDropdownMenu.appendChild(btn);
187189
});

src/renderer/features/featured/index.js

Lines changed: 207 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,70 @@
11
(function registerFeatured(){
22
const namespace = window.features = window.features || {};
33

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+
464
function renderItem(item, container) {
565
const html = `
666
<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">
868
<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'"/>
969
</div>
1070
<div class="featured-body">
@@ -18,13 +78,25 @@
1878
function init(options = {}) {
1979
const container = options.container || document.getElementById('featuredBanner');
2080
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
2292
if (!container) return null;
2393
container.innerHTML = `
2494
<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>
28100
<div class="featured-dots" aria-hidden="true"></div>
29101
</div>`;
30102

@@ -46,10 +118,34 @@
46118
if (!items || !items.length) { slot.innerHTML = ''; return; }
47119
idx = (index + items.length) % items.length;
48120
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+
}
49146
const btns = dots.querySelectorAll('.dot');
50147
btns.forEach((b,i)=> b.classList.toggle('active', i===idx));
51148
// add click handler on slot to open details
52-
const item = items[idx];
53149
const itemEl = slot.querySelector('.featured-item');
54150
if (itemEl) {
55151
itemEl.style.cursor = 'pointer';
@@ -58,8 +154,32 @@
58154
}
59155

60156
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+
}
63183
function resetTimer() { if (timer) { clearInterval(timer); } timer = setInterval(nextItem, 6000); }
64184

65185
prev.addEventListener('click', () => { prevItem(); resetTimer(); });
@@ -75,7 +195,85 @@
75195
if (e.key === 'ArrowRight') { nextItem(); resetTimer(); }
76196
});
77197

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); } });
79277
}
80278

81279
namespace.featured = Object.freeze({ init });

src/renderer/features/search/index.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@
8080
categoriesApi.updateDropdownLabel(stateRef, translateFn, iconMapOverride);
8181
}
8282
applySearchInternal();
83+
// ensure banner visibility reacts immediately when the user focuses search
84+
try { updateBannerVisibility(); } catch(e) {}
85+
// If the user clicked into the search field and there's already a query,
86+
// hide the banner immediately (defensive: the focus may be briefly lost
87+
// during the tab switch flow, so force hiding here).
88+
try {
89+
const q = (searchInput && String(searchInput.value || '').trim()) || '';
90+
if (q.length) {
91+
const banner = document.getElementById('featuredBanner');
92+
if (banner) banner.hidden = true;
93+
}
94+
} catch (e) {}
8395
}
8496

8597
function hidePanelsForNonApps() {
@@ -99,6 +111,8 @@
99111

100112
function resetSearchModeIfNeeded() {
101113
if (!searchMode) return;
114+
// If the user is currently focused on the search input, don't blur it — they are actively interacting
115+
if (searchInput && document.activeElement === searchInput) return;
102116
searchMode = false;
103117
if (searchInput) {
104118
lastSearchValue = searchInput.value || '';
@@ -209,17 +223,36 @@
209223
debounceDelay = typeof options.debounceDelay === 'number' ? options.debounceDelay : 140;
210224
isSandboxed = typeof options.isSandboxed === 'function' ? options.isSandboxed : () => false;
211225

226+
function updateBannerVisibility() {
227+
try {
228+
const banner = document.getElementById('featuredBanner');
229+
if (!banner) return;
230+
const q = (searchInput && String(searchInput.value || '').trim()) || '';
231+
// Hide the banner only when the search is active OR the input is focused with non-empty query.
232+
// If the input simply holds previous text but isn't active, keep the banner visible.
233+
const shouldHideBecauseOfSearch = q.length && (searchMode === true || (searchInput && document.activeElement === searchInput));
234+
if (shouldHideBecauseOfSearch) {
235+
banner.hidden = true;
236+
return;
237+
}
238+
// otherwise follow normal rules (only visible on Applications tab and not in details-mode)
239+
banner.hidden = !(stateRef && (stateRef.activeCategory === 'all')) || document.body.classList.contains('details-mode');
240+
} catch (e) { /* defensive: ignore DOM issues */ }
241+
}
242+
212243
if (searchInput) {
213244
searchInput.addEventListener('focus', handleFocus);
214245
const debouncer = getDebounce();
215-
const onInput = debouncer(() => applySearchInternal(), debounceDelay);
246+
const onInput = debouncer(() => { applySearchInternal(); updateBannerVisibility(); }, debounceDelay);
216247
searchInput.addEventListener('input', onInput);
248+
// ensure initial state
249+
updateBannerVisibility();
217250
}
218251

219252
return Object.freeze({
220253
applySearch: () => applySearchInternal(),
221254
getSearchState: () => ({ searchMode, lastSearchValue }),
222-
resetSearch: () => { searchMode = false; lastSearchValue = ''; }
255+
resetSearch: () => { searchMode = false; lastSearchValue = ''; if (searchInput) { searchInput.value = ''; } try { const b = document.getElementById('featuredBanner'); if (b) b.hidden = !(stateRef && stateRef.activeCategory === 'all'); } catch(e){} }
223256
});
224257
}
225258

0 commit comments

Comments
 (0)