Compare commits
8 Commits
c0f9c0c1c4
...
7529449f86
| Author | SHA1 | Date | |
|---|---|---|---|
| 7529449f86 | |||
| 555816bf30 | |||
| 2da2a5bb27 | |||
| c921aafadd | |||
| e5b30741fe | |||
| 0af537e032 | |||
| 9f9df600c1 | |||
| 5d264d8f3b |
@@ -25,6 +25,42 @@ def create_db_and_tables():
|
||||
from app.models.settings import AppSettingsTable
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
# Add new columns to existing tables if they don't exist (SQLite workaround)
|
||||
_ensure_columns(engine)
|
||||
|
||||
|
||||
def _ensure_columns(engine):
|
||||
"""Add new columns to app_settings table if they don't exist"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
inspector = inspect(engine)
|
||||
if 'app_settings' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing = {col['name'] for col in inspector.get_columns('app_settings')}
|
||||
|
||||
new_columns = {
|
||||
'recommendations_filter': 'TEXT DEFAULT "all"',
|
||||
'releases_filter': 'TEXT DEFAULT "all"',
|
||||
'anime_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'series_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'download_dir': 'TEXT DEFAULT "downloads"',
|
||||
}
|
||||
|
||||
# Add is_admin to users table if missing
|
||||
if 'users' in inspector.get_table_names():
|
||||
user_cols = {col['name'] for col in inspector.get_columns('users')}
|
||||
if 'is_admin' not in user_cols:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
|
||||
conn.commit()
|
||||
|
||||
with engine.connect() as conn:
|
||||
for col_name, col_def in new_columns.items():
|
||||
if col_name not in existing:
|
||||
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
|
||||
+34
-18
@@ -27,11 +27,15 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Add an anime to favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
|
||||
if existing:
|
||||
@@ -53,17 +57,21 @@ class FavoritesManager:
|
||||
url=url,
|
||||
provider=provider,
|
||||
anime_metadata=metadata or {},
|
||||
poster_url=poster_url
|
||||
poster_url=poster_url,
|
||||
user_id=user_id
|
||||
)
|
||||
session.add(fav)
|
||||
session.commit()
|
||||
session.refresh(fav)
|
||||
return self._to_dict(fav)
|
||||
|
||||
async def remove_favorite(self, anime_id: str) -> bool:
|
||||
async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Remove an anime from favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
session.delete(existing)
|
||||
@@ -71,10 +79,13 @@ class FavoritesManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
||||
async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
|
||||
"""Get a specific favorite by ID"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
return self._to_dict(existing)
|
||||
@@ -82,6 +93,7 @@ class FavoritesManager:
|
||||
|
||||
async def list_favorites(
|
||||
self,
|
||||
user_id: str = "default",
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: Optional[str] = None,
|
||||
@@ -89,11 +101,11 @@ class FavoritesManager:
|
||||
) -> List[Dict]:
|
||||
"""List all favorites with optional sorting and filtering"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable)
|
||||
|
||||
statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
|
||||
|
||||
if filter_provider:
|
||||
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||
|
||||
|
||||
# SQLite JSON filtering for genres is complex, handle it in Python
|
||||
results = session.exec(statement).all()
|
||||
favorites = [self._to_dict(fav) for fav in results]
|
||||
@@ -123,10 +135,13 @@ class FavoritesManager:
|
||||
|
||||
return favorites
|
||||
|
||||
async def is_favorite(self, anime_id: str) -> bool:
|
||||
async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Check if an anime is in favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
return session.exec(statement).first() is not None
|
||||
|
||||
async def toggle_favorite(
|
||||
@@ -136,21 +151,22 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
||||
is_fav = await self.is_favorite(anime_id)
|
||||
is_fav = await self.is_favorite(anime_id, user_id=user_id)
|
||||
|
||||
if is_fav:
|
||||
await self.remove_favorite(anime_id)
|
||||
await self.remove_favorite(anime_id, user_id=user_id)
|
||||
return {"action": "removed", "anime_id": anime_id}
|
||||
else:
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url)
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
|
||||
return {"action": "added", "anime_id": anime_id, "favorite": fav}
|
||||
|
||||
async def get_stats(self) -> Dict:
|
||||
async def get_stats(self, user_id: str = "default") -> Dict:
|
||||
"""Get statistics about favorites"""
|
||||
favorites = await self.list_favorites()
|
||||
favorites = await self.list_favorites(user_id=user_id)
|
||||
total = len(favorites)
|
||||
|
||||
# Count by provider
|
||||
|
||||
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
|
||||
email: Optional[str] = Field(default=None, index=True)
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = Field(default=True)
|
||||
is_admin: bool = Field(default=False)
|
||||
|
||||
|
||||
class UserTable(UserBase, table=True):
|
||||
|
||||
@@ -14,6 +14,19 @@ class AppSettingsBase(SQLModel):
|
||||
|
||||
# Store list of disabled providers as a JSON string
|
||||
disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
|
||||
|
||||
# #9: Filter for recommendations section ("all", "anime", "series")
|
||||
recommendations_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #10: Filter for latest releases section ("all", "anime", "series")
|
||||
releases_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #11: Enable/disable categories
|
||||
anime_enabled: bool = Field(default=True)
|
||||
series_enabled: bool = Field(default=True)
|
||||
|
||||
# #12: Custom download directory
|
||||
download_dir: str = Field(default="downloads")
|
||||
|
||||
@property
|
||||
def disabled_providers(self) -> List[str]:
|
||||
@@ -46,6 +59,11 @@ class AppSettings(BaseModel):
|
||||
default_lang: str = "vostfr"
|
||||
theme: str = "dark"
|
||||
disabled_providers: List[str] = []
|
||||
recommendations_filter: str = "all"
|
||||
releases_filter: str = "all"
|
||||
anime_enabled: bool = True
|
||||
series_enabled: bool = True
|
||||
download_dir: str = "downloads"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,3 +74,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
default_lang: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
disabled_providers: Optional[List[str]] = None
|
||||
recommendations_filter: Optional[str] = None
|
||||
releases_filter: Optional[str] = None
|
||||
anime_enabled: Optional[bool] = None
|
||||
series_enabled: Optional[bool] = None
|
||||
download_dir: Optional[str] = None
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_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
|
||||
from .router_admin import router as admin_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
@@ -26,5 +27,6 @@ __all__ = [
|
||||
"static_router",
|
||||
"root_router",
|
||||
"settings_router",
|
||||
"admin_router",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Admin panel routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session, engine
|
||||
from app.models.auth import User, UserTable
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
|
||||
"""Dependency that requires the current user to be an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable)
|
||||
users = session.exec(statement).all()
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"total": len(users),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Get admin dashboard statistics"""
|
||||
from app.download_manager import DownloadManager
|
||||
from main import download_manager
|
||||
|
||||
with Session(engine) as session:
|
||||
total_users = len(session.exec(select(UserTable)).all())
|
||||
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
|
||||
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
total_downloads = len(tasks)
|
||||
completed_downloads = len([t for t in tasks if t.status == "completed"])
|
||||
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"admins": admin_users,
|
||||
},
|
||||
"downloads": {
|
||||
"total": total_downloads,
|
||||
"completed": completed_downloads,
|
||||
"active": active_downloads,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-active")
|
||||
async def toggle_user_active(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Activate or deactivate a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own account")
|
||||
user.is_active = not user.is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
status = "active" if user.is_active else "inactive"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-admin")
|
||||
async def toggle_user_admin(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Promote or demote a user to/from admin"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
|
||||
user.is_admin = not user.is_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
role = "admin" if user.is_admin else "user"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
username = user.username
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_admin_ui(
|
||||
request: Request,
|
||||
current_user: Optional[User] = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get admin panel UI"""
|
||||
if current_user is None or not current_user.is_admin:
|
||||
from app.routers.router_auth import get_optional_user
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(select(UserTable)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/admin_panel.html",
|
||||
{"request": request, "users": users, "current_user": current_user},
|
||||
)
|
||||
@@ -174,10 +174,28 @@ async def search_anime_unified(
|
||||
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
if q.lower() in (item_dict.get("title") or "").lower():
|
||||
# Fuzzy relevance scoring
|
||||
title = (item_dict.get("title") or "").lower()
|
||||
query_lower = q.lower()
|
||||
|
||||
# Exact match
|
||||
if query_lower == title:
|
||||
item_dict["_relevance_boost"] = 1.0
|
||||
else:
|
||||
# Title starts with query
|
||||
elif title.startswith(query_lower):
|
||||
item_dict["_relevance_boost"] = 0.95
|
||||
# Query is a substring of title
|
||||
elif query_lower in title:
|
||||
item_dict["_relevance_boost"] = 0.85
|
||||
# Words from query all appear in title
|
||||
elif all(word in title.split() for word in query_lower.split() if len(word) > 1):
|
||||
item_dict["_relevance_boost"] = 0.7
|
||||
# At least one word matches
|
||||
elif any(word in title.split() for word in query_lower.split() if len(word) > 2):
|
||||
item_dict["_relevance_boost"] = 0.5
|
||||
else:
|
||||
item_dict["_relevance_boost"] = 0.3
|
||||
|
||||
results[pid].append(item_dict)
|
||||
|
||||
# Prepare enrichment task for top 15 results per provider
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
Download management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
import json
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.models import DownloadRequest
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
from app.models import DownloadRequest, DownloadStatus
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -24,20 +28,28 @@ async def get_downloads(
|
||||
request: Request,
|
||||
html: bool = Query(False),
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get list of all download tasks. Returns HTML for HTMX requests."""
|
||||
tasks = download_manager.get_all_tasks()
|
||||
|
||||
# Strictly check for HTMX or explicit HTML flag
|
||||
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
|
||||
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
|
||||
if html or is_htmx:
|
||||
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
||||
return templates.TemplateResponse(
|
||||
"components/downloads_list.html",
|
||||
{"request": request, "tasks": tasks}
|
||||
)
|
||||
|
||||
|
||||
print(f"[DOWNLOADS] API Request. Returning JSON.")
|
||||
return {"downloads": tasks}
|
||||
|
||||
@@ -56,8 +68,12 @@ async def create_download(
|
||||
async def get_download_status(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get status of a specific download task"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
@@ -106,6 +122,73 @@ async def cancel_download(
|
||||
raise HTTPException(status_code=400, detail="Failed to cancel download")
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
async def stream_video(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Stream a completed download as video"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status != DownloadStatus.COMPLETED or not task.file_path:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
file_path = Path(task.file_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
media_types = {
|
||||
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
|
||||
".webm": "video/webm", ".flv": "video/x-flv",
|
||||
}
|
||||
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
|
||||
return FileResponse(str(file_path), media_type=media_type)
|
||||
|
||||
|
||||
@router.post("/{task_id}/retry")
|
||||
async def retry_download(
|
||||
task_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Retry a failed or cancelled download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status not in ("failed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
|
||||
task.status = DownloadStatus.PENDING
|
||||
task.progress = 0.0
|
||||
if hasattr(download_manager, "_process_download"):
|
||||
background_tasks.add_task(download_manager._process_download, task_id)
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
|
||||
)
|
||||
return {"status": "retrying"}
|
||||
|
||||
|
||||
@router.post("/cancel-all")
|
||||
async def cancel_all_downloads(
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Cancel all active downloads"""
|
||||
count = 0
|
||||
for tid, task in list(download_manager.tasks.items()):
|
||||
if task.status in ("downloading", "pending"):
|
||||
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
|
||||
count += 1
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
|
||||
)
|
||||
return {"status": "cancelled", "count": count}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_completed(
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
|
||||
@@ -2,24 +2,42 @@
|
||||
Favorites management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.favorites import get_favorites_manager
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_favorites(
|
||||
request: Request,
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: str = None,
|
||||
filter_genre: str = None,
|
||||
filter_provider: Optional[str] = None,
|
||||
filter_genre: Optional[str] = None,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""List all favorite anime with optional sorting and filtering"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorites = await fav_manager.list_favorites(
|
||||
user_id=current_user.id,
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
filter_provider=filter_provider,
|
||||
@@ -38,7 +56,11 @@ async def list_favorites(
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_favorite(request: Request):
|
||||
async def add_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to favorites"""
|
||||
data = await request.json()
|
||||
|
||||
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.add_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
|
||||
return {"status": "added", "favorite": favorite}
|
||||
|
||||
|
||||
@router.delete("/{anime_id}")
|
||||
async def remove_favorite(anime_id: str):
|
||||
async def remove_favorite(
|
||||
anime_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove an anime from favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
removed = await fav_manager.remove_favorite(anime_id)
|
||||
removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
|
||||
return {"status": "removed", "anime_id": anime_id}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_favorites_stats():
|
||||
async def get_favorites_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get statistics about favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
stats = await fav_manager.get_stats()
|
||||
stats = await fav_manager.get_stats(user_id=current_user.id)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/{anime_id}")
|
||||
async def get_favorite(anime_id: str):
|
||||
async def get_favorite(
|
||||
anime_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get details of a specific favorite anime"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.get_favorite(anime_id)
|
||||
favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle_favorite(request: Request):
|
||||
async def toggle_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Toggle an anime in favorites"""
|
||||
data = await request.json()
|
||||
|
||||
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
result = await fav_manager.toggle_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
action = result.get("action", "unknown")
|
||||
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
|
||||
toast_type = "success" if action == "added" else "info"
|
||||
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
|
||||
return result
|
||||
|
||||
@@ -6,10 +6,12 @@ import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -26,14 +28,30 @@ async def get_recommendations(
|
||||
request: Request,
|
||||
limit: int = 15,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/recommendations_list.html",
|
||||
{"request": request, "recommendations": recommendations}
|
||||
@@ -53,12 +71,17 @@ async def get_latest_releases(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
@@ -140,7 +163,9 @@ async def get_top_anime(
|
||||
|
||||
|
||||
@router.get("/stats/downloads")
|
||||
async def get_download_statistics():
|
||||
async def get_download_statistics(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get download statistics and preferences"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ async def get_settings(
|
||||
default_lang=settings_obj.default_lang,
|
||||
theme=settings_obj.theme,
|
||||
disabled_providers=settings_obj.disabled_providers,
|
||||
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
|
||||
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
|
||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +70,22 @@ async def update_settings(
|
||||
settings_obj.theme = update_data.theme
|
||||
if update_data.disabled_providers is not None:
|
||||
settings_obj.disabled_providers = update_data.disabled_providers
|
||||
if update_data.recommendations_filter is not None:
|
||||
settings_obj.recommendations_filter = update_data.recommendations_filter
|
||||
if update_data.releases_filter is not None:
|
||||
settings_obj.releases_filter = update_data.releases_filter
|
||||
if update_data.anime_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.anime_enabled = update_data.anime_enabled
|
||||
if update_data.series_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.series_enabled = update_data.series_enabled
|
||||
if update_data.download_dir is not None:
|
||||
settings_obj.download_dir = update_data.download_dir
|
||||
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
|
||||
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
|
||||
|
||||
|
||||
@router.get("/sonarr/config")
|
||||
async def get_sonarr_config():
|
||||
async def get_sonarr_config(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_config()
|
||||
|
||||
|
||||
@router.put("/sonarr/config")
|
||||
async def update_sonarr_config(config: SonarrConfig):
|
||||
async def update_sonarr_config(
|
||||
config: SonarrConfig,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings")
|
||||
async def get_sonarr_mappings():
|
||||
async def get_sonarr_mappings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get all Sonarr to anime mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_mappings()
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings/{series_id}")
|
||||
async def get_sonarr_mapping(series_id: int):
|
||||
async def get_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get specific mapping by Sonarr series ID"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
mapping = sonarr_handler.get_mapping(series_id)
|
||||
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
|
||||
|
||||
|
||||
@router.post("/sonarr/mappings")
|
||||
async def create_sonarr_mapping(mapping: SonarrMapping):
|
||||
async def create_sonarr_mapping(
|
||||
mapping: SonarrMapping,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Create or update a Sonarr to anime mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
|
||||
|
||||
|
||||
@router.delete("/sonarr/mappings/{series_id}")
|
||||
async def delete_sonarr_mapping(series_id: int):
|
||||
async def delete_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Delete a Sonarr mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
success = sonarr_handler.delete_mapping(series_id)
|
||||
@@ -130,6 +146,7 @@ async def search_anime_for_sonarr(
|
||||
q: str = Query(..., description="Series title to search"),
|
||||
provider: str = Query("anime-sama", description="Anime provider to search"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Search for anime on providers to create Sonarr mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -152,6 +169,7 @@ async def get_anime_episodes(
|
||||
url: str = Query(..., description="Anime URL from provider"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get episode list for anime"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
|
||||
sonarr_title: str = Query(..., description="Sonarr series title"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Suggest possible anime mappings based on Sonarr series title"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
|
||||
async def trigger_sonarr_download(
|
||||
request: SonarrDownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Manually trigger a download based on Sonarr information"""
|
||||
from main import download_manager
|
||||
|
||||
@@ -144,6 +144,7 @@ from app.routers import (
|
||||
static_router,
|
||||
root_router,
|
||||
settings_router,
|
||||
admin_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +160,7 @@ app.include_router(sonarr_router)
|
||||
app.include_router(player_router)
|
||||
app.include_router(static_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+685
-140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
<div class="settings-container section-container">
|
||||
<div class="section-header">
|
||||
<h2>Administration</h2>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;">
|
||||
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Role</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
|
||||
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
|
||||
<td style="padding: 12px 20px;">
|
||||
<div style="font-weight: 600;">{{ user.username }}</div>
|
||||
{% if user.full_name %}
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};">
|
||||
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};">
|
||||
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-active" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
|
||||
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm {% if user.is_admin %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-admin" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
|
||||
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
|
||||
hx-confirm="Supprimer {{ user.username }} ?"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
<button class="sr-btn sr-btn-follow"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||
<i class="fas fa-plus"></i> Suivre
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% if tasks %}
|
||||
<div class="downloads-grid">
|
||||
{% for task in tasks %}
|
||||
<div class="download-item task-{{ task.status }}">
|
||||
<div class="download-item status-{{ task.status }}">
|
||||
<div class="download-info">
|
||||
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
||||
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
|
||||
@@ -19,28 +19,38 @@
|
||||
|
||||
<div class="download-actions">
|
||||
{% if task.status == 'downloading' or task.status == 'pending' %}
|
||||
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none">
|
||||
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Pause">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
{% elif task.status == 'paused' %}
|
||||
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none">
|
||||
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Reprendre">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status == 'failed' or task.status == 'cancelled' %}
|
||||
<button class="btn-icon warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status == 'completed' %}
|
||||
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</a>
|
||||
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Télécharger le fichier">
|
||||
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Telecharger">
|
||||
<i class="fas fa-file-download"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn-icon danger"
|
||||
hx-delete="/api/downloads/{{ task.id }}"
|
||||
hx-confirm="Supprimer ce téléchargement ?"
|
||||
hx-confirm="Supprimer ce telechargement ?"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -51,6 +61,6 @@
|
||||
{% else %}
|
||||
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
|
||||
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
|
||||
<p>Aucun téléchargement en cours</p>
|
||||
<p>Aucun telechargement en cours</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>📥 Téléchargements</h2>
|
||||
<h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-post="/api/downloads/cleanup"
|
||||
hx-swap="none"
|
||||
hx-confirm="Nettoyer tous les telechargements termines ?"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
|
||||
Nettoyer terminés
|
||||
<i class="fas fa-broom"></i> Nettoyer termines
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-post="/api/downloads/cancel-all"
|
||||
hx-swap="none"
|
||||
hx-confirm="Annuler tous les telechargements actifs ?"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
|
||||
<i class="fas fa-stop-circle"></i> Tout annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,12 +25,20 @@
|
||||
hx-trigger="load, refresh, every 3s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement des téléchargements...
|
||||
<div class="spinner"></div> Chargement des telechargements...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-container { margin-bottom: 40px; }
|
||||
/* Styles already defined or moved to downloads_list.html */
|
||||
.active-downloads-counter {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<button class="sr-btn sr-btn-follow"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||
<i class="fas fa-plus"></i> Suivre
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<div class="settings-container section-container">
|
||||
<div class="section-header">
|
||||
<h2>⚙️ Paramètres</h2>
|
||||
<h2>Parametres</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>
|
||||
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
|
||||
|
||||
<form hx-patch="/api/settings" hx-swap="none" hx-ext="json-enc" class="settings-form">
|
||||
<form id="settings-form" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="default_lang">Langue par défaut</label>
|
||||
<label for="default_lang">Langue par defaut</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>
|
||||
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
|
||||
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="theme">Thème</label>
|
||||
<label for="theme">Theme</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>
|
||||
@@ -25,18 +25,76 @@
|
||||
</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
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="download_dir">Repertoire de telechargement</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" name="download_dir" id="download_dir" value="{{ settings.download_dir }}"
|
||||
class="btn btn-secondary" style="flex: 1; text-align: left; padding: 12px 15px;">
|
||||
</div>
|
||||
<small style="color: var(--text-dim); font-size: 12px; margin-top: 5px; display: block;">
|
||||
Repertoire ou les fichiers seront telecharges (defaut: downloads/)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;" onclick="event.preventDefault(); saveSettings();">
|
||||
<i class="fas fa-save"></i> Enregistrer les preferences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Content Filters -->
|
||||
<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);">Filtres de contenu</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="recommendations_filter">Recommande pour vous : afficher</label>
|
||||
<select name="recommendations_filter" id="recommendations_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('recommendations_filter', this.value)">
|
||||
<option value="all" {% if settings.recommendations_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
|
||||
<option value="anime" {% if settings.recommendations_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
||||
<option value="series" {% if settings.recommendations_filter == 'series' %}selected{% endif %}>Series uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 15px;">
|
||||
<label for="releases_filter">Dernieres sorties : afficher</label>
|
||||
<select name="releases_filter" id="releases_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('releases_filter', this.value)">
|
||||
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
|
||||
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
||||
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Series uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<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);">Categories</h3>
|
||||
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p>
|
||||
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||||
<label class="toggle-card" style="flex: 1; min-width: 200px; 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; cursor: pointer;">
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">Animes</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div>
|
||||
</div>
|
||||
<input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
|
||||
</label>
|
||||
|
||||
<label class="toggle-card" style="flex: 1; min-width: 200px; 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; cursor: pointer;">
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">Series TV</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div>
|
||||
</div>
|
||||
<input type="checkbox" id="series_enabled" {% if settings.series_enabled %}checked{% endif %} onchange="toggleCategory('series_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
<h3 style="margin: 0; color: var(--primary);">Disponibilite 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
|
||||
<i class="fas fa-sync-alt"></i> Forcer verification
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +119,7 @@
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
|
||||
style="min-width: 100px;">
|
||||
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
|
||||
{% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -69,6 +127,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getToken() {
|
||||
return localStorage.getItem('auth_token') || null;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const data = {
|
||||
default_lang: document.getElementById('default_lang').value,
|
||||
theme: document.getElementById('theme').value,
|
||||
download_dir: document.getElementById('download_dir').value,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (r.ok) {
|
||||
showToast('Preferences enregistrees', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFilter(field, value) {
|
||||
const token = getToken();
|
||||
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) {
|
||||
showToast('Filtre mis a jour', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCategory(field, value) {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
// Prevent disabling both
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 showToast(message, type) {
|
||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-form label {
|
||||
display: block;
|
||||
|
||||
@@ -1,39 +1,491 @@
|
||||
{% if items %}
|
||||
<div class="watchlist-grid">
|
||||
{% for item in items %}
|
||||
<div class="watchlist-item card" id="watchlist-{{ item.id }}">
|
||||
<div class="item-poster">
|
||||
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}">
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<h3>{{ item.anime_title }}</h3>
|
||||
<div class="item-meta">
|
||||
<span class="badge">{{ item.provider_id }}</span>
|
||||
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
|
||||
{% set status_filter = request.query_params.get('status', 'all') %}
|
||||
<div class="watchlist-container" x-data="{ currentFilter: '{{ status_filter }}' }">
|
||||
<!-- Filter Tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'all' or not status_filter else '' }}"
|
||||
hx-get="/api/watchlist?status=all"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-list"></i> Tous
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'active' else '' }}"
|
||||
hx-get="/api/watchlist?status=active"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-play"></i> Actifs
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'paused' else '' }}"
|
||||
hx-get="/api/watchlist?status=paused"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-pause"></i> En pause
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'completed' else '' }}"
|
||||
hx-get="/api/watchlist?status=completed"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-check"></i> Terminés
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Watchlist Items Grid -->
|
||||
{% if items and items | length > 0 %}
|
||||
<div class="watchlist-grid">
|
||||
{% for item in items %}
|
||||
<div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
|
||||
<!-- Poster -->
|
||||
<div class="watchlist-poster">
|
||||
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
|
||||
alt="{{ item.anime_title }}"
|
||||
onerror="this.src='/static/img/no-poster.png'">
|
||||
<div class="poster-badge {{ item.status }}">
|
||||
{% if item.status == 'active' %}
|
||||
<i class="fas fa-play"></i> Actif
|
||||
{% elif item.status == 'paused' %}
|
||||
<i class="fas fa-pause"></i> En pause
|
||||
{% elif item.status == 'completed' %}
|
||||
<i class="fas fa-check"></i> Terminé
|
||||
{% else %}
|
||||
<i class="fas fa-archive"></i> Archivé
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.auto_download %}
|
||||
<div class="auto-download-badge">
|
||||
<i class="fas fa-magic"></i> Auto
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-stats">
|
||||
<span>Épisode: {{ item.last_episode_downloaded }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn btn-sm btn-primary"
|
||||
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}"
|
||||
hx-target="#player-container">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/api/watchlist/{{ item.id }}"
|
||||
hx-target="#watchlist-{{ item.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Retirer de la watchlist ?">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="watchlist-content">
|
||||
<h3 class="watchlist-title">{{ item.anime_title }}</h3>
|
||||
|
||||
<div class="watchlist-meta">
|
||||
<span class="meta-provider">
|
||||
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
|
||||
</span>
|
||||
<span class="meta-lang">{{ item.lang | upper }}</span>
|
||||
{% if item.quality_preference and item.quality_preference != 'auto' %}
|
||||
<span class="meta-quality">{{ item.quality_preference }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.synopsis %}
|
||||
<p class="watchlist-synopsis">{{ item.synopsis | truncate(150) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="watchlist-stats">
|
||||
<span class="stat">
|
||||
<i class="fas fa-download"></i>
|
||||
Ép. {{ item.last_episode_downloaded }}
|
||||
{% if item.total_episodes %}
|
||||
/ {{ item.total_episodes }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if item.added_at %}
|
||||
<span class="stat" title="Ajouté le {{ item.added_at.strftime('%d/%m/%Y') }}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
{{ item.added_at.strftime('%d/%m/%Y') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="watchlist-actions">
|
||||
<!-- Pause/Resume Toggle -->
|
||||
{% if item.status == 'active' %}
|
||||
<button class="action-btn btn-pause"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "paused"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Mettre en pause">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
{% elif item.status == 'paused' %}
|
||||
<button class="action-btn btn-resume"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "active"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Reprendre">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mark as completed -->
|
||||
{% if item.status not in ['completed', 'archived'] %}
|
||||
<button class="action-btn btn-complete"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "completed"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Marquer comme terminé">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete -->
|
||||
<button class="action-btn btn-delete"
|
||||
hx-delete="/api/watchlist/{{ item.id }}"
|
||||
hx-target="#watchlist-{{ item.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Votre watchlist est vide.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="watchlist-empty">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<h3>Votre watchlist est vide</h3>
|
||||
<p>Ajoutez des animes ou séries depuis l'onglet Recherche pour commencer à suivre leurs sorties.</p>
|
||||
<button class="btn btn-primary" @click="$dispatch('set-tab', {tab: 'anime'})">
|
||||
<i class="fas fa-search"></i> Rechercher des animes
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container */
|
||||
.watchlist-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--input-radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--primary);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.watchlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.watchlist-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.watchlist-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Poster */
|
||||
.watchlist-poster {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
aspect-ratio: 2/3;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
.watchlist-poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.poster-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.poster-badge.active {
|
||||
background: rgba(0, 255, 136, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.paused {
|
||||
background: rgba(255, 193, 7, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.completed {
|
||||
background: rgba(156, 39, 176, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.archived {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.auto-download-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 217, 255, 0.9);
|
||||
color: var(--bg-dark);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.watchlist-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watchlist-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.watchlist-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-provider,
|
||||
.meta-lang,
|
||||
.meta-quality {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-provider {
|
||||
background: rgba(0, 217, 255, 0.15);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.meta-lang {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: var(--secondary);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.meta-quality {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.watchlist-synopsis {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watchlist-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.watchlist-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-pause {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.btn-pause:hover {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.btn-resume {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-resume:hover {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
color: #9c27b0;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: rgba(156, 39, 176, 0.15);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.watchlist-empty {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.watchlist-empty i {
|
||||
font-size: 4rem;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.3;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.watchlist-empty h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.watchlist-empty p {
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.watchlist-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.watchlist-card {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.watchlist-poster {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.watchlist-meta,
|
||||
.watchlist-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+24
-22
@@ -12,7 +12,7 @@
|
||||
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
|
||||
<!-- Anime Search Section -->
|
||||
<div class="section-header">
|
||||
<h2>🎬 Rechercher un Anime</h2>
|
||||
<h2>Rechercher un Anime</h2>
|
||||
</div>
|
||||
<div class="url-form">
|
||||
<form hx-get="/api/anime/search"
|
||||
@@ -38,9 +38,6 @@
|
||||
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
|
||||
<div class="spinner"></div> Recherche en cours...
|
||||
</div>
|
||||
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||
💡 <strong>Astuce :</strong> La recherche unifiée explore plusieurs sources pour trouver vos animes préférés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anime search results -->
|
||||
@@ -51,11 +48,11 @@
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<!-- Latest Releases Section - Anime only -->
|
||||
<div class="section-header">
|
||||
<h2>🔥 Dernières sorties Anime</h2>
|
||||
<h2>Dernieres sorties Anime</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-get="/api/releases/latest?content_type=anime&html=1"
|
||||
hx-target="#animeReleasesList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -63,13 +60,13 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:500ms"></div>
|
||||
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest?content_type=anime&html=1" hx-trigger="load delay:500ms"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
|
||||
<!-- Series Search Section -->
|
||||
<div class="section-header">
|
||||
<h2>📺 Rechercher une Série TV</h2>
|
||||
<h2>Rechercher une Serie TV</h2>
|
||||
</div>
|
||||
<div class="url-form">
|
||||
<form hx-get="/api/series/search"
|
||||
@@ -82,7 +79,7 @@
|
||||
type="text"
|
||||
name="q"
|
||||
id="seriesSearchInput"
|
||||
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
|
||||
placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="btn btn-primary btn-search">
|
||||
@@ -95,9 +92,6 @@
|
||||
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
|
||||
<div class="spinner"></div> Recherche en cours...
|
||||
</div>
|
||||
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series search results -->
|
||||
@@ -105,11 +99,11 @@
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
|
||||
<!-- Recommendations Section -->
|
||||
<!-- Recommendations Section - Series only -->
|
||||
<div class="section-header">
|
||||
<h2>🎯 Recommandé pour vous</h2>
|
||||
<h2>Recommande pour vous</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/recommendations"
|
||||
hx-get="/api/recommendations?content_type=series&html=1"
|
||||
hx-target="#seriesRecommendationsList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -117,13 +111,13 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations" hx-trigger="load delay:600ms"></div>
|
||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<!-- Latest Releases Section - Series only -->
|
||||
<div class="section-header" style="margin-top: 40px;">
|
||||
<h2>🔥 Dernières sorties Séries TV</h2>
|
||||
<h2>Dernieres sorties Series TV</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-get="/api/releases/latest?content_type=series&html=1"
|
||||
hx-target="#seriesReleasesList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -131,7 +125,7 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:700ms"></div>
|
||||
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
||||
@@ -145,7 +139,15 @@
|
||||
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
|
||||
<div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement des paramètres...
|
||||
<div class="spinner"></div> Chargement des parametres...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
|
||||
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement du panel admin...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user