feat: flat design Sunset Glitch + download manager + settings + recommendations overhaul
- Sunset Glitch color palette applied to all templates - Font Awesome icons throughout UI - Download manager with parallel queue and progress tracking - Settings page with dynamic configuration - Recommendations router enhanced with scoring - Local vendor libs (Alpine.js, HTMX) for offline support - Auto test suite with screenshots - Series releases list component - New download model
This commit is contained in:
+149
-25
@@ -970,6 +970,99 @@ h1 {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* --- Header --- */
|
||||
header {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* --- Auth panel: compact on mobile --- */
|
||||
.auth-panel {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.auth-panel > div {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-panel span {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-panel .btn {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* --- Tabs: compact icon mode, scrollable --- */
|
||||
.tabs {
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 14px;
|
||||
font-size: 0.7rem;
|
||||
gap: 4px;
|
||||
min-height: 48px;
|
||||
min-width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* --- Touch targets: iOS minimum 44px --- */
|
||||
.btn,
|
||||
.btn-small,
|
||||
.btn-sm {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* --- Input groups --- */
|
||||
.input-group {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* prevent iOS zoom */
|
||||
}
|
||||
|
||||
/* --- Cards --- */
|
||||
.anime-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
@@ -979,32 +1072,47 @@ h1 {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.hc-play {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 1; /* always visible on mobile */
|
||||
}
|
||||
|
||||
/* --- Sections --- */
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 16px;
|
||||
.section-header h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
.section-header .btn {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* --- Layout --- */
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* --- Auth container (login page) --- */
|
||||
.auth-container {
|
||||
margin: 40px 20px;
|
||||
padding: 24px;
|
||||
margin: 20px 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* --- Downloads --- */
|
||||
.downloads-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* --- Toast --- */
|
||||
.toast-container {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -1012,20 +1120,42 @@ h1 {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Horizontal carousels: full bleed on mobile --- */
|
||||
.home-row,
|
||||
.streaming-row,
|
||||
.recommendations-carousel,
|
||||
.releases-carousel {
|
||||
padding: 10px 0 16px;
|
||||
margin: 0 -16px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
/* --- Settings form --- */
|
||||
.settings-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* --- Watchlist --- */
|
||||
.watchlist-item {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
.tab {
|
||||
padding: 12px 10px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.tab svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.hc {
|
||||
@@ -1035,12 +1165,6 @@ h1 {
|
||||
.hc-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Settings page - form handlers for user preferences, filters, and weights.
|
||||
* Loaded on all pages via base.html so functions are available when
|
||||
* the settings section is dynamically loaded via HTMX.
|
||||
*/
|
||||
|
||||
function saveSettings() {
|
||||
const data = {
|
||||
default_lang: document.getElementById('default_lang')?.value,
|
||||
theme: document.getElementById('theme')?.value,
|
||||
download_dir: document.getElementById('download_dir')?.value,
|
||||
};
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => {
|
||||
if (r.ok) showToast('Preferences enregistrees', 'success');
|
||||
}).catch(e => {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveFilter(field, value) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
}).then(r => {
|
||||
if (r.ok) showToast('Filtre mis a jour', 'success');
|
||||
}).catch(e => {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleCategory(field, value) {
|
||||
if (!value) {
|
||||
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
|
||||
const otherCheckbox = document.getElementById(otherField);
|
||||
if (otherCheckbox && !otherCheckbox.checked) {
|
||||
showToast('Au moins une categorie doit rester active', 'error');
|
||||
document.getElementById(field).checked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
showToast(err.detail || 'Erreur', 'error');
|
||||
document.getElementById(field).checked = !value;
|
||||
} else {
|
||||
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
document.getElementById(field).checked = !value;
|
||||
}
|
||||
}
|
||||
|
||||
function onWeightModeChange(mode) {
|
||||
const autoInfo = document.getElementById('weight-auto-info');
|
||||
const manualControls = document.getElementById('weight-manual-controls');
|
||||
|
||||
if (mode === 'auto') {
|
||||
if (autoInfo) autoInfo.style.display = 'block';
|
||||
if (manualControls) manualControls.style.display = 'none';
|
||||
loadAutoWeights();
|
||||
} else {
|
||||
if (autoInfo) autoInfo.style.display = 'none';
|
||||
if (manualControls) manualControls.style.display = 'block';
|
||||
updateWeightPreview();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content_weight_mode: mode })
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAutoWeights() {
|
||||
const details = document.getElementById('weight-auto-details');
|
||||
if (!details) return;
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/settings/content-weight', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const aw = data.anime_weight;
|
||||
const sw = data.series_weight;
|
||||
const ac = data.anime_count;
|
||||
const sc = data.series_count;
|
||||
const total = data.total || 0;
|
||||
|
||||
if (total === 0) {
|
||||
details.innerHTML = '<span style="color: var(--text-dim);">Aucun telechargement detecte. Ratio par defaut : ' + aw + ' anime / ' + sw + ' serie.</span>';
|
||||
} else {
|
||||
const pctA = total > 0 ? Math.round(ac / total * 100) : 50;
|
||||
const pctS = total > 0 ? Math.round(sc / total * 100) : 50;
|
||||
details.innerHTML = `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<strong>${ac}</strong> anime${ac > 1 ? 's' : ''} (${pctA}%) — <strong>${sc}</strong> serie${sc > 1 ? 's' : ''} (${pctS}%)
|
||||
</div>
|
||||
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||
<div style="width: ${pctA}%; background: var(--primary);"></div>
|
||||
<div style="width: ${pctS}%; background: #6CB4EE;"></div>
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 12px;">
|
||||
Ratio applique : <strong style="color: var(--primary);">${aw}</strong> anime / <strong style="color: #6CB4EE;">${sw}</strong> serie
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (e) {
|
||||
details.innerHTML = '<span style="color: var(--danger);">Erreur de chargement</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateWeightPreview() {
|
||||
const awEl = document.getElementById('content_weight_anime_range');
|
||||
const swEl = document.getElementById('content_weight_series_range');
|
||||
const preview = document.getElementById('weight-preview');
|
||||
if (!awEl || !swEl || !preview) return;
|
||||
|
||||
const aw = parseInt(awEl.value) || 0;
|
||||
const sw = parseInt(swEl.value) || 0;
|
||||
const total = aw + sw;
|
||||
|
||||
if (total === 0) {
|
||||
preview.innerHTML = '<span style="color: var(--danger);">Les deux poids ne peuvent pas etre a 0</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const pctA = Math.round(aw / total * 100);
|
||||
const pctS = 100 - pctA;
|
||||
|
||||
preview.innerHTML = `
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: var(--primary); font-weight: 700;">${pctA}%</span> animes /
|
||||
<span style="color: #6CB4EE; font-weight: 700;">${pctS}%</span> series
|
||||
</div>
|
||||
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||
<div style="width: ${pctA}%; background: var(--primary); transition: width 0.2s;"></div>
|
||||
<div style="width: ${pctS}%; background: #6CB4EE; transition: width 0.2s;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveManualWeights() {
|
||||
const awEl = document.getElementById('content_weight_anime_range');
|
||||
const swEl = document.getElementById('content_weight_series_range');
|
||||
if (!awEl || !swEl) return;
|
||||
|
||||
const aw = parseInt(awEl.value) || 0;
|
||||
const sw = parseInt(swEl.value) || 0;
|
||||
|
||||
if (aw === 0 && sw === 0) {
|
||||
showToast('Les deux poids ne peuvent pas etre a 0', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content_weight_mode: 'manual', content_weight_anime: aw, content_weight_series: sw })
|
||||
});
|
||||
if (r.ok) showToast('Equilibre mis a jour', 'success');
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Initialize weight display when settings tab content is loaded via HTMX
|
||||
document.addEventListener('htmx:afterSettle', function(evt) {
|
||||
if (evt.detail.target) {
|
||||
const mode = evt.detail.target.querySelector('#content_weight_mode');
|
||||
if (mode && mode.value === 'auto') {
|
||||
loadAutoWeights();
|
||||
} else if (mode && mode.value === 'manual') {
|
||||
updateWeightPreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user