feat: add Settings tab with provider management and language preferences
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Implemented AppSettings model and table using SQLModel.
- Created Settings router with endpoints for preferences and provider toggling.
- Added Settings tab to the UI with real-time health status of providers.
- Integrated language and provider filtering into anime and series search logic.
- Updated templates to respect user-defined settings.
This commit is contained in:
root
2026-03-26 16:12:29 +00:00
parent 3b405f2a42
commit 0c03f4f4a6
15 changed files with 383 additions and 25 deletions
+1
View File
@@ -22,6 +22,7 @@ def create_db_and_tables():
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
from app.models.settings import AppSettingsTable
SQLModel.metadata.create_all(engine)
+1
View File
@@ -69,3 +69,4 @@ from .auth import UserTable
from .watchlist import WatchlistItemTable, WatchlistSettingsTable
from .favorites import FavoriteTable
from .sonarr import SonarrMappingTable, SonarrConfigTable
from .settings import AppSettingsTable
+58
View File
@@ -0,0 +1,58 @@
"""Models for application settings with SQLModel support"""
import uuid
import json
from pydantic import BaseModel, Field as PydanticField
from typing import Optional, List, Dict
from datetime import datetime
from sqlmodel import SQLModel, Field, Column, String
class AppSettingsBase(SQLModel):
"""Base schema for application settings"""
default_lang: str = Field(default="vostfr")
theme: str = Field(default="dark")
# Store list of disabled providers as a JSON string
disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
@property
def disabled_providers(self) -> List[str]:
try:
return json.loads(self.disabled_providers_json or "[]")
except:
return []
@disabled_providers.setter
def disabled_providers(self, value: List[str]):
self.disabled_providers_json = json.dumps(value or [])
class AppSettingsTable(AppSettingsBase, table=True):
"""Database table for application settings"""
__tablename__ = "app_settings"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
user_id: str = Field(foreign_key="users.id", index=True, unique=True)
updated_at: datetime = Field(default_factory=datetime.now)
class AppSettings(BaseModel):
"""Application settings (API Response)"""
default_lang: str = "vostfr"
theme: str = "dark"
disabled_providers: List[str] = []
class Config:
from_attributes = True
class AppSettingsUpdate(BaseModel):
"""Model for updating application settings"""
default_lang: Optional[str] = None
theme: Optional[str] = None
disabled_providers: Optional[List[str]] = None
+5 -2
View File
@@ -10,8 +10,9 @@ from app.routers.router_recommendations import router as recommendations_router
from app.routers.router_watchlist import router as watchlist_router
from app.routers.router_sonarr import router as sonarr_router
from app.routers.router_player import router as player_router
from app.routers.router_static import router as static_router
from app.routers.router_root import router as root_router
from .router_static import router as static_router
from .router_root import router as root_router
from .router_settings import router as settings_router
__all__ = [
"auth_router",
@@ -24,4 +25,6 @@ __all__ = [
"player_router",
"static_router",
"root_router",
"settings_router",
]
+37 -7
View File
@@ -11,7 +11,12 @@ import hashlib
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.database import get_session
from app.models.settings import AppSettingsTable
from app.routers.router_auth import get_current_user_from_token
from app.models.auth import User
from app.download_manager import DownloadManager
from app.downloaders import (
AnimeSamaDownloader,
@@ -67,6 +72,8 @@ async def search_anime_unified(
lang: str = "vostfr",
include_metadata: bool = False,
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""
Search across all anime providers.
@@ -75,6 +82,11 @@ async def search_anime_unified(
print(f"\n[SEARCH] Starting search for '{q}'. html={html}")
start_time = time.time()
# Get user settings for disabled providers
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
results = {}
# 1. Prepare search tasks (Generic + Legacy)
@@ -84,8 +96,9 @@ async def search_anime_unified(
# Generic YAML providers
active_generic = providers_manager.get_active_providers()
for provider in active_generic:
search_tasks.append(provider.search(q))
task_metadata.append({"id": provider.id, "type": "generic"})
if provider.id not in disabled_providers:
search_tasks.append(provider.search(q))
task_metadata.append({"id": provider.id, "type": "generic"})
# Legacy providers
legacy_downloaders = {
@@ -94,8 +107,9 @@ async def search_anime_unified(
"vostfree": VostfreeDownloader(),
}
for pid, dl in legacy_downloaders.items():
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
task_metadata.append({"id": pid, "type": "legacy"})
if pid not in disabled_providers:
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
task_metadata.append({"id": pid, "type": "legacy"})
# 2. Run searches in parallel
all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True)
@@ -163,7 +177,11 @@ async def search_anime_unified(
print("[SEARCH] Returning HTML response")
return templates.TemplateResponse(
"components/anime_search_results.html",
{"request": request, "results": results}
{
"request": request,
"results": results,
"settings": settings_obj
}
)
print("[SEARCH] Returning JSON response")
@@ -181,6 +199,8 @@ async def search_series_unified(
q: str,
lang: str = "vf",
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""
Search across all TV series providers (FS7, etc.)
@@ -191,6 +211,12 @@ async def search_series_unified(
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}")
start_time = time.time()
# Get user settings for disabled providers
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
results = {}
series_downloaders = {
"fs7": FS7Downloader(),
@@ -200,7 +226,7 @@ async def search_series_unified(
provider_ids = []
for provider_id, provider in get_series_providers().items():
if provider_id in series_downloaders:
if provider_id in series_downloaders and provider_id not in disabled_providers:
downloader = series_downloaders[provider_id]
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
@@ -221,7 +247,11 @@ async def search_series_unified(
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/series_search_results.html",
{"request": request, "results": results}
{
"request": request,
"results": results,
"settings": settings_obj
}
)
return {"query": q, "lang": lang, "results": results}
+160
View File
@@ -0,0 +1,160 @@
"""Application settings routes for Ohm Stream Downloader API"""
import json
from typing import List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.database import get_session
from app.models.auth import User, UserTable
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
from app.routers.router_auth import get_current_user_from_token
from app.providers import get_anime_providers, get_series_providers
from app.providers_manager import providers_manager
router = APIRouter(prefix="/api/settings", tags=["settings"])
templates = Jinja2Templates(directory="templates")
@router.get("", response_model=AppSettings)
async def get_settings(
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""Get current application settings for the user"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
if not settings_obj:
# Create default settings if they don't exist
settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj)
session.commit()
session.refresh(settings_obj)
return AppSettings(
default_lang=settings_obj.default_lang,
theme=settings_obj.theme,
disabled_providers=settings_obj.disabled_providers
)
@router.patch("", response_model=AppSettings)
async def update_settings(
update_data: AppSettingsUpdate,
response: Response,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""Update application settings for the user"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
if not settings_obj:
settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj)
if update_data.default_lang is not None:
settings_obj.default_lang = update_data.default_lang
if update_data.theme is not None:
settings_obj.theme = update_data.theme
if update_data.disabled_providers is not None:
settings_obj.disabled_providers = update_data.disabled_providers
session.add(settings_obj)
session.commit()
session.refresh(settings_obj)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres enregistrés", "type": "success"}})
return AppSettings(
default_lang=settings_obj.default_lang,
theme=settings_obj.theme,
disabled_providers=settings_obj.disabled_providers
)
@router.get("/providers/availability")
async def get_providers_availability(
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""Get list of providers with their availability and enabled status"""
# Get user settings
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
# Get health status
health_status = providers_manager.get_all_status()
# Combine anime and series providers
all_providers = {**get_anime_providers(), **get_series_providers()}
result = []
for pid, info in all_providers.items():
status_info = health_status.get(pid, {"status": "unknown"})
result.append({
"id": pid,
"name": info["name"],
"icon": info.get("icon", "🎬"),
"status": status_info["status"],
"enabled": pid not in disabled_providers,
"error": status_info.get("error")
})
return result
@router.post("/providers/{provider_id}/toggle")
async def toggle_provider(
provider_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""Toggle a provider's enabled/disabled status"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
settings_obj = session.exec(statement).first()
if not settings_obj:
settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj)
disabled = settings_obj.disabled_providers
if provider_id in disabled:
disabled.remove(provider_id)
enabled = True
else:
disabled.append(provider_id)
enabled = False
settings_obj.disabled_providers = disabled
session.add(settings_obj)
session.commit()
status_text = "activé" if enabled else "désactivé"
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Fournisseur {provider_id} {status_text}", "type": "success"}})
return {"id": provider_id, "enabled": enabled}
@router.get("/ui")
async def get_settings_ui(
request: Request,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
):
"""Return the settings UI fragment for HTMX"""
# Reuse existing endpoints logic
settings = await get_settings(current_user, session)
providers = await get_providers_availability(current_user, session)
return templates.TemplateResponse(
"components/settings_section.html",
{
"request": request,
"settings": settings,
"providers": providers
}
)
+2
View File
@@ -126,6 +126,7 @@ from app.routers import (
player_router,
static_router,
root_router,
settings_router,
)
@@ -140,6 +141,7 @@ app.include_router(watchlist_router)
app.include_router(sonarr_router)
app.include_router(player_router)
app.include_router(static_router)
app.include_router(settings_router)
if __name__ == "__main__":
+2 -2
View File
@@ -26,7 +26,7 @@ function switchTab(tabName) {
// Handle URL hash on page load
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads'];
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings'];
if (validTabs.includes(hash)) {
// Short delay to ensure Alpine is ready
setTimeout(() => switchTab(hash), 100);
@@ -37,7 +37,7 @@ if (window.location.hash) {
window.addEventListener('hashchange', function() {
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads'];
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings'];
if (validTabs.includes(hash)) {
switchTab(hash);
}
+7 -6
View File
@@ -1,4 +1,4 @@
{% macro anime_card(anime, in_watchlist=False) %}
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
<div class="anime-card" id="anime-{{ anime.url | hash }}">
<div class="anime-poster">
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
@@ -17,7 +17,7 @@
<div class="anime-overlay">
<div class="overlay-buttons">
<button class="btn btn-primary btn-circle"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML"
title="Play">
@@ -32,20 +32,21 @@
<div class="anime-meta-tags">
<span class="badge">{{ anime.provider_id or 'Anime' }}</span>
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
{% if anime.metadata and anime.metadata.status %}
<span class="badge" style="color: var(--primary)">{{ anime.metadata.status }}</span>
<span class="badge" style="color: #aaa">{{ anime.metadata.status }}</span>
{% endif %}
</div>
<div class="anime-card-buttons">
<button class="btn btn-primary btn-small"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-eye"></i> <span>Regarder</span>
</button>
<button class="btn btn-secondary btn-small"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-download"></i> <span>Télécharger</span>
@@ -55,7 +56,7 @@
{% if not in_watchlist %}
<button class="btn btn-secondary btn-small btn-block"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}"}'
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}", "lang": "{{ lang }}"}'
hx-swap="none"
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
<i class="fas fa-plus"></i> Watchlist
@@ -7,7 +7,7 @@
<h3 class="provider-title">{{ provider_id | upper }}</h3>
<div class="anime-grid">
{% for anime in items %}
{{ anime_card(anime) }}
{{ anime_card(anime, lang=settings.default_lang if settings else 'vostfr') }}
{% endfor %}
</div>
</div>
+9
View File
@@ -65,5 +65,14 @@
</svg>
Téléchargements
</button>
<button class="tab"
:class="{ 'active': activeTab === 'settings' }"
@click="activeTab = 'settings'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'settings' } }))">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Paramètres
</button>
</nav>
</header>
+7 -6
View File
@@ -1,4 +1,4 @@
{% macro series_card(series, in_watchlist=False) %}
{% macro series_card(series, in_watchlist=False, lang='vf') %}
<div class="anime-card" id="series-{{ series.url | hash }}">
<div class="anime-poster">
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
@@ -9,7 +9,7 @@
<div class="anime-overlay">
<div class="overlay-buttons">
<button class="btn btn-primary btn-circle"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML"
title="Play">
@@ -21,18 +21,19 @@
<div class="anime-info">
<h3 class="anime-title" title="{{ series.title }}">{{ series.title }}</h3>
<div class="anime-meta-tags">
<span class="badge">FS7</span>
<span class="badge">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
</div>
<div class="anime-card-buttons">
<button class="btn btn-primary btn-small"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-eye"></i> <span>Regarder</span>
</button>
<button class="btn btn-secondary btn-small"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-download"></i> <span>Télécharger</span>
@@ -42,7 +43,7 @@
{% if not in_watchlist %}
<button class="btn btn-secondary btn-small btn-block"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ series.url }}", "anime_title": "{{ series.title }}", "provider_id": "fs7", "lang": "vf"}'
hx-vals='{"anime_url": "{{ series.url }}", "anime_title": "{{ series.title }}", "provider_id": "{{ series.provider_id or 'fs7' }}", "lang": "{{ lang }}"}'
hx-swap="none"
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
<i class="fas fa-plus"></i> Watchlist
@@ -7,7 +7,7 @@
<h3 class="provider-title">{{ provider_id | upper }}</h3>
<div class="anime-grid">
{% for series in items %}
{{ series_card(series) }}
{{ series_card(series, lang=settings.default_lang if settings else 'vf') }}
{% endfor %}
</div>
</div>
@@ -0,0 +1,84 @@
<div class="settings-container section-container">
<div class="section-header">
<h2>⚙️ Paramètres</h2>
</div>
<!-- General Preferences -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<h3 style="margin-bottom: 20px; color: var(--primary);">Général</h3>
<form hx-patch="/api/settings" hx-swap="none" hx-ext="json-enc" class="settings-form">
<div class="form-group">
<label for="default_lang">Langue par défaut</label>
<select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR (Version Originale Sous-Titrée Français)</option>
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF (Version Française)</option>
</select>
</div>
<div class="form-group" style="margin-top: 20px;">
<label for="theme">Thème</label>
<select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
<option value="oled" {% if settings.theme == 'oled' %}selected{% endif %}>OLED (Noir absolu)</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;">
<i class="fas fa-save"></i> Enregistrer les préférences
</button>
</form>
</div>
<!-- Providers Management -->
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; color: var(--primary);">Disponibilité des Fournisseurs</h3>
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none">
<i class="fas fa-sync-alt"></i> Forcer vérification
</button>
</div>
<div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
{% for provider in providers %}
<div class="provider-status-card" style="padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 1.5rem;">{{ provider.icon }}</span>
<div>
<div style="font-weight: 600;">{{ provider.name }}</div>
<div style="font-size: 0.75rem; display: flex; align-items: center; gap: 5px;">
<span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}#aaa{% endif %};"></span>
<span style="color: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-dim){% endif %}; text-transform: uppercase; font-weight: 800;">
{{ provider.status | upper }}
</span>
</div>
</div>
</div>
<button class="btn {% if provider.enabled %}btn-secondary{% else %}btn-accent{% endif %} btn-sm"
hx-post="/api/settings/providers/{{ provider.id }}/toggle"
hx-target="closest .settings-container"
hx-get="/api/settings/ui"
hx-trigger="click delay:200ms"
style="min-width: 100px;">
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
</button>
</div>
{% endfor %}
</div>
</div>
</div>
<style>
.settings-form label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-dim);
}
.status-dot {
display: inline-block;
box-shadow: 0 0 5px currentColor;
}
</style>
+8
View File
@@ -142,6 +142,14 @@
{% include "components/downloads_section.html" %}
</div>
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
<div hx-get="/api/settings/ui" hx-trigger="load, set-tab[detail.tab === 'settings'] from:window" hx-swap="innerHTML">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement des paramètres...
</div>
</div>
</div>
</div>
<!-- End of main-content -->