Ajout de la webapp Docker (FastAPI + HTML/JS vanilla)
- Backend FastAPI avec auth par cookie (users dans config.json) - Upload PDF drag & drop, progression en temps réel (SSE) - Identification des séries via Ollama (config URL dans config.json) - Téléchargement ICS par série + historique des traitements - Bouton vider le cache (site web + mapping LLM) - Docker Swarm ready (docker-compose.yml + Dockerfile) - Compatible iOS/Android/PC (responsive mobile-first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ cache/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
webapp/data/
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dépendances système minimales
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Dépendances Python
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Code applicatif
|
||||||
|
COPY app.py core.py ./
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
|
# Répertoires de données (sera écrasé par le volume en production)
|
||||||
|
RUN mkdir -p /app/data/cache /app/data/jobs /app/data/uploads
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -sf http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
app.py - Backend FastAPI pour planning2ics web app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Depends, File, HTTPException, Request, Response, UploadFile, Cookie
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
import core
|
||||||
|
|
||||||
|
# ── Chemins ───────────────────────────────────────────────────────────────────
|
||||||
|
CONFIG_PATH = Path("/app/config.json")
|
||||||
|
DATA_DIR = Path("/app/data")
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
# Création des répertoires de données au démarrage
|
||||||
|
for _d in ["cache", "jobs", "uploads"]:
|
||||||
|
(DATA_DIR / _d).mkdir(parents=True, exist_ok=True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(title="planning2ics", docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||||
|
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
sessions: dict[str, str] = {} # token → username
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(session: Optional[str] = Cookie(default=None)) -> str:
|
||||||
|
if not session or session not in sessions:
|
||||||
|
raise HTTPException(status_code=401, detail="Non authentifié")
|
||||||
|
return sessions[session]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return FileResponse("/app/static/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
async def login(request: Request, response: Response):
|
||||||
|
data = await request.json()
|
||||||
|
config = load_config()
|
||||||
|
username = data.get("username", "")
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
for user in config["auth"]["users"]:
|
||||||
|
if user["username"] == username and user["password"] == password:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
sessions[token] = username
|
||||||
|
response.set_cookie(
|
||||||
|
key="session", value=token,
|
||||||
|
httponly=True, samesite="lax", max_age=86400 * 7
|
||||||
|
)
|
||||||
|
return {"ok": True, "username": username}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Identifiants incorrects")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
async def logout(response: Response, session: Optional[str] = Cookie(default=None)):
|
||||||
|
if session and session in sessions:
|
||||||
|
del sessions[session]
|
||||||
|
response.delete_cookie("session")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/me")
|
||||||
|
async def me(user: str = Depends(get_current_user)):
|
||||||
|
return {"username": user}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config publique ───────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/config")
|
||||||
|
async def public_config(user: str = Depends(get_current_user)):
|
||||||
|
cfg = load_config()
|
||||||
|
return {
|
||||||
|
"ollama_url": cfg["ollama"]["url"],
|
||||||
|
"cluster_model": cfg["ollama"]["cluster_model"],
|
||||||
|
"local_model": cfg["ollama"]["local_model"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Traitement PDF ────────────────────────────────────────────────────────────
|
||||||
|
jobs: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/process")
|
||||||
|
async def start_processing(
|
||||||
|
files: list[UploadFile],
|
||||||
|
user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
upload_dir = DATA_DIR / "uploads" / job_id
|
||||||
|
upload_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
saved_paths = []
|
||||||
|
pdf_names = []
|
||||||
|
for file in files:
|
||||||
|
if not file.filename.lower().endswith('.pdf'):
|
||||||
|
continue
|
||||||
|
dest = upload_dir / file.filename
|
||||||
|
dest.write_bytes(await file.read())
|
||||||
|
saved_paths.append(dest)
|
||||||
|
pdf_names.append(file.filename)
|
||||||
|
|
||||||
|
if not saved_paths:
|
||||||
|
raise HTTPException(400, "Aucun fichier PDF valide fourni")
|
||||||
|
|
||||||
|
jobs[job_id] = {
|
||||||
|
"status": "running",
|
||||||
|
"queue": queue,
|
||||||
|
"result": None,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"pdf_names": pdf_names,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.create_task(_run_processing(job_id, saved_paths, queue))
|
||||||
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_processing(job_id: str, pdf_paths: list, queue: asyncio.Queue):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
queue.put({"type": "progress", "message": msg}), loop
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None, lambda: core.process_pdfs(pdf_paths, config, DATA_DIR, log)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sauvegarder les ICS
|
||||||
|
output_dir = DATA_DIR / "jobs" / job_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
series_list = []
|
||||||
|
for series_title, data in result.items():
|
||||||
|
(output_dir / data['filename']).write_bytes(data['bytes'])
|
||||||
|
series_list.append({
|
||||||
|
"name": series_title,
|
||||||
|
"filename": data['filename'],
|
||||||
|
"event_count": data['event_count'],
|
||||||
|
})
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"created_at": jobs[job_id]["created_at"],
|
||||||
|
"pdf_names": jobs[job_id]["pdf_names"],
|
||||||
|
"series": series_list,
|
||||||
|
}
|
||||||
|
(output_dir / "metadata.json").write_text(
|
||||||
|
json.dumps(meta, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs[job_id]["status"] = "done"
|
||||||
|
jobs[job_id]["result"] = series_list
|
||||||
|
await queue.put({"type": "done", "series": series_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
jobs[job_id]["status"] = "error"
|
||||||
|
await queue.put({"type": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/progress/{job_id}")
|
||||||
|
async def progress_stream(job_id: str, user: str = Depends(get_current_user)):
|
||||||
|
if job_id not in jobs:
|
||||||
|
raise HTTPException(404, "Job introuvable")
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
q = jobs[job_id]["queue"]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(q.get(), timeout=30)
|
||||||
|
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||||
|
if msg["type"] in ("done", "error"):
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield "data: {\"type\":\"ping\"}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Jobs ──────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/jobs")
|
||||||
|
async def list_jobs(user: str = Depends(get_current_user)):
|
||||||
|
result = []
|
||||||
|
jobs_dir = DATA_DIR / "jobs"
|
||||||
|
if jobs_dir.exists():
|
||||||
|
for d in sorted(jobs_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
meta = d / "metadata.json"
|
||||||
|
if meta.exists():
|
||||||
|
result.append(json.loads(meta.read_text()))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/{job_id}")
|
||||||
|
async def get_job(job_id: str, user: str = Depends(get_current_user)):
|
||||||
|
if job_id in jobs:
|
||||||
|
j = jobs[job_id]
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": j["status"],
|
||||||
|
"pdf_names": j["pdf_names"],
|
||||||
|
"series": j.get("result"),
|
||||||
|
"created_at": j["created_at"],
|
||||||
|
}
|
||||||
|
meta = DATA_DIR / "jobs" / job_id / "metadata.json"
|
||||||
|
if meta.exists():
|
||||||
|
return json.loads(meta.read_text())
|
||||||
|
raise HTTPException(404, "Job introuvable")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/download/{job_id}/{filename}")
|
||||||
|
async def download_ics(
|
||||||
|
job_id: str, filename: str, user: str = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Sécurité : empêcher path traversal
|
||||||
|
filename = Path(filename).name
|
||||||
|
ics_path = DATA_DIR / "jobs" / job_id / filename
|
||||||
|
if not ics_path.exists():
|
||||||
|
raise HTTPException(404, "Fichier introuvable")
|
||||||
|
return FileResponse(
|
||||||
|
ics_path, media_type="text/calendar", filename=filename,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/cache/status")
|
||||||
|
async def cache_status(user: str = Depends(get_current_user)):
|
||||||
|
cache_dir = DATA_DIR / "cache"
|
||||||
|
return {
|
||||||
|
"website_cached": (cache_dir / "website_catalog.json").exists(),
|
||||||
|
"series_cached": (cache_dir / "series_mapping.json").exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/cache")
|
||||||
|
async def clear_cache(user: str = Depends(get_current_user)):
|
||||||
|
cache_dir = DATA_DIR / "cache"
|
||||||
|
deleted = []
|
||||||
|
for name in ["website_catalog.json", "series_mapping.json"]:
|
||||||
|
p = cache_dir / name
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
deleted.append(name)
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"ollama": {
|
||||||
|
"url": "http://192.168.7.119:11434",
|
||||||
|
"cluster_model": "qwen3.5:cloud",
|
||||||
|
"local_model": "qwen3:8b"
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"calendar_url": "https://www.opera-orchestre-montpellier.fr/calendrier/?saisons=32669",
|
||||||
|
"base_url": "https://www.opera-orchestre-montpellier.fr"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"session_secret": "changez-cette-cle-secrete-en-production",
|
||||||
|
"users": [
|
||||||
|
{"username": "admin", "password": "changeme"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
"""
|
||||||
|
core.py - Logique métier pour planning2ics web app.
|
||||||
|
Adapté de planning2ics.py pour usage web (config injectable, callback de progression).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time as time_module
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, date, time, timedelta
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import pdfplumber
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
MONTH_MAP = {
|
||||||
|
"JANV": 1, "JAN": 1, "JANVIER": 1,
|
||||||
|
"FEV": 2, "FEVR": 2, "FEVRIER": 2,
|
||||||
|
"MARS": 3, "MAR": 3,
|
||||||
|
"AVRIL": 4, "AVR": 4,
|
||||||
|
"MAI": 5, "JUIN": 6,
|
||||||
|
"JUIL": 7, "JUILLET": 7,
|
||||||
|
"AOUT": 8, "AOÛT": 8,
|
||||||
|
"SEPT": 9, "SEP": 9, "SEPTEMBRE": 9,
|
||||||
|
"OCT": 10, "OCTOBRE": 10,
|
||||||
|
"NOV": 11, "NOVEMBRE": 11,
|
||||||
|
"DEC": 12, "DÉC": 12, "DECEMBRE": 12, "DÉCEMBRE": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
CONCERT_KEYWORDS = {
|
||||||
|
'concert', 'représentation', 'générale publique',
|
||||||
|
'raccord', 'italienne', 'scène orch'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utilitaires ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_note(note: str) -> str:
|
||||||
|
return re.sub(r'\s+', ' ', note).strip()
|
||||||
|
|
||||||
|
def is_public_event(titre: str) -> bool:
|
||||||
|
t = titre.lower()
|
||||||
|
return any(k in t for k in CONCERT_KEYWORDS)
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
clean = re.sub(r'[^\w\s\-éèêàùûîôç]', '', name, flags=re.UNICODE)
|
||||||
|
return clean.strip().replace(' ', '_')[:80] or 'SERIE_INCONNUE'
|
||||||
|
|
||||||
|
def extract_year_month_from_filename(filename: str):
|
||||||
|
year_match = re.search(r'(\d{4})', filename)
|
||||||
|
year = int(year_match.group(1)) if year_match else 2026
|
||||||
|
stem = Path(filename).stem.upper()
|
||||||
|
main_month = 1
|
||||||
|
for key, val in MONTH_MAP.items():
|
||||||
|
if key in stem:
|
||||||
|
main_month = val
|
||||||
|
break
|
||||||
|
return year, main_month
|
||||||
|
|
||||||
|
def parse_date(date_str: str, main_year: int, main_month: int) -> Optional[date]:
|
||||||
|
try:
|
||||||
|
day, month = map(int, date_str.strip().split('/'))
|
||||||
|
if month > main_month + 3:
|
||||||
|
year = main_year - 1
|
||||||
|
elif month < main_month - 3:
|
||||||
|
year = main_year + 1
|
||||||
|
else:
|
||||||
|
year = main_year
|
||||||
|
return date(year, month, day)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_time(s: str) -> Optional[time]:
|
||||||
|
m = re.match(r'(\d{1,2}):(\d{2})', s.strip())
|
||||||
|
return time(int(m.group(1)), int(m.group(2))) if m else None
|
||||||
|
|
||||||
|
def parse_horaires(s: str):
|
||||||
|
s = s.strip()
|
||||||
|
m = re.match(r'(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})', s)
|
||||||
|
if m:
|
||||||
|
return parse_time(m.group(1)), parse_time(m.group(2))
|
||||||
|
m = re.match(r'(\d{1,2}:\d{2})', s)
|
||||||
|
if m:
|
||||||
|
return parse_time(m.group(1)), None
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Extraction PDF ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def extract_events_from_pdf(pdf_path: Path) -> list:
|
||||||
|
events = []
|
||||||
|
main_year, main_month = extract_year_month_from_filename(pdf_path.name)
|
||||||
|
current_date = None
|
||||||
|
|
||||||
|
with pdfplumber.open(pdf_path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
for table in (page.extract_tables() or []):
|
||||||
|
for row in table:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
cells = [str(c).strip() if c else '' for c in row]
|
||||||
|
if cells[0].lower() == 'jour' or len(cells) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = cells[1]
|
||||||
|
horaires = cells[2]
|
||||||
|
titre = cells[3]
|
||||||
|
lieu = cells[4]
|
||||||
|
note = cells[5] if len(cells) > 5 else ''
|
||||||
|
dec = cells[6] if len(cells) > 6 else ''
|
||||||
|
voy = cells[7] if len(cells) > 7 else ''
|
||||||
|
|
||||||
|
if date_str and re.match(r'\d{1,2}/\d{2}', date_str):
|
||||||
|
parsed = parse_date(date_str, main_year, main_month)
|
||||||
|
if parsed:
|
||||||
|
current_date = parsed
|
||||||
|
|
||||||
|
if not current_date:
|
||||||
|
continue
|
||||||
|
if 'repos' in horaires.lower():
|
||||||
|
continue
|
||||||
|
if not re.search(r'\d{1,2}:\d{2}', horaires):
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_time, end_time = parse_horaires(horaires)
|
||||||
|
if not start_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
'date': current_date,
|
||||||
|
'horaires': horaires,
|
||||||
|
'start_time': start_time,
|
||||||
|
'end_time': end_time,
|
||||||
|
'titre': titre,
|
||||||
|
'lieu': lieu,
|
||||||
|
'note': normalize_note(note),
|
||||||
|
'dec': dec,
|
||||||
|
'voy': voy,
|
||||||
|
'source_file': pdf_path.name,
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scraping site web ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def scrape_catalog(config: dict, cache_dir: Path,
|
||||||
|
log: Callable = None, force: bool = False) -> dict:
|
||||||
|
cache_file = cache_dir / "website_catalog.json"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not force and cache_file.exists():
|
||||||
|
if log:
|
||||||
|
log("Catalogue site web chargé depuis le cache")
|
||||||
|
with open(cache_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
headers = {'User-Agent': 'Mozilla/5.0 (compatible; planning2ics/1.0)'}
|
||||||
|
calendar_url = config['site']['calendar_url']
|
||||||
|
site_base = config['site']['base_url']
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log("Scraping du site web de l'opéra...")
|
||||||
|
|
||||||
|
resp = requests.get(calendar_url, headers=headers, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||||
|
|
||||||
|
event_links = {}
|
||||||
|
for a in soup.find_all('a', href=True):
|
||||||
|
href = a['href']
|
||||||
|
if '/evenements/' in href and href.rstrip('/') != f'{site_base}/evenements':
|
||||||
|
full_url = href if href.startswith('http') else site_base + href
|
||||||
|
h3 = a.find('h3')
|
||||||
|
cat_tag = a.find('p')
|
||||||
|
title = h3.get_text(strip=True) if h3 else a.get_text(strip=True)
|
||||||
|
category = cat_tag.get_text(strip=True) if cat_tag else ''
|
||||||
|
if title and len(title) > 3:
|
||||||
|
event_links[title] = {'url': full_url, 'category': category}
|
||||||
|
|
||||||
|
catalog = {}
|
||||||
|
total = len(event_links)
|
||||||
|
if log:
|
||||||
|
log(f"{total} événements trouvés sur le site, récupération des descriptions...")
|
||||||
|
|
||||||
|
for i, (title, info) in enumerate(event_links.items()):
|
||||||
|
if log and i % 20 == 0:
|
||||||
|
log(f"Descriptions : {i}/{total}")
|
||||||
|
try:
|
||||||
|
r = requests.get(info['url'], headers=headers, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
page_soup = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
catalog[title] = {
|
||||||
|
'url': info['url'],
|
||||||
|
'description': _extract_description(page_soup),
|
||||||
|
'category': info['category'],
|
||||||
|
}
|
||||||
|
time_module.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
catalog[title] = {
|
||||||
|
'url': info['url'], 'description': '', 'category': info['category']
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(cache_file, 'w') as f:
|
||||||
|
json.dump(catalog, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log(f"Catalogue mis en cache : {len(catalog)} événements")
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_description(soup: BeautifulSoup) -> str:
|
||||||
|
for selector in ['div.wp-block-group', 'div.entry-content', 'article', 'main']:
|
||||||
|
container = soup.select_one(selector)
|
||||||
|
if container:
|
||||||
|
for tag in container.find_all(['nav', 'header', 'footer', 'button', 'form']):
|
||||||
|
tag.decompose()
|
||||||
|
lines = [
|
||||||
|
l.strip() for l in container.get_text('\n', strip=True).splitlines()
|
||||||
|
if l.strip() and len(l.strip()) > 15
|
||||||
|
][:40]
|
||||||
|
if lines:
|
||||||
|
return '\n'.join(lines)
|
||||||
|
return soup.get_text('\n', strip=True)[:2000]
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _llm_call(prompt: str, ollama_url: str, model: str) -> str:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{ollama_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": True,
|
||||||
|
"options": {"temperature": 0.05, "num_predict": 16384},
|
||||||
|
"think": False,
|
||||||
|
},
|
||||||
|
stream=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content = ""
|
||||||
|
for line in resp.iter_lines():
|
||||||
|
if line:
|
||||||
|
chunk = json.loads(line)
|
||||||
|
content += chunk.get('message', {}).get('content', '')
|
||||||
|
if chunk.get('done'):
|
||||||
|
break
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_parallel_heuristic(note: str, catalog: dict) -> Optional[str]:
|
||||||
|
m = re.match(r"^\([AB]'?\)\s*:\s*[\"']?(.+?)[\"']?\s*$", note, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
inner = m.group(1).strip().lower()
|
||||||
|
for title in catalog:
|
||||||
|
if inner in title.lower() or title.lower() in inner:
|
||||||
|
return title
|
||||||
|
return m.group(1).strip().strip('"\'')
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_notes_global(unique_notes: set, catalog: dict, config: dict,
|
||||||
|
cache_dir: Path, log: Callable = None,
|
||||||
|
force: bool = False) -> dict:
|
||||||
|
cache_file = cache_dir / "series_mapping.json"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not force and cache_file.exists():
|
||||||
|
if log:
|
||||||
|
log("Mapping des séries chargé depuis le cache")
|
||||||
|
with open(cache_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
catalog_titles = sorted(catalog.keys())
|
||||||
|
titles_list = '\n'.join(f'- "{t}"' for t in catalog_titles)
|
||||||
|
notes_list = '\n'.join(f'- {repr(n)}' for n in sorted(unique_notes) if n.strip())
|
||||||
|
|
||||||
|
prompt = f"""Tu analyses le planning interne de l'Opéra Orchestre National Montpellier.
|
||||||
|
|
||||||
|
Voici les titres OFFICIELS des événements de la saison (depuis le site web) :
|
||||||
|
{titles_list}
|
||||||
|
|
||||||
|
Voici toutes les notes du planning interne (certaines sont des variantes de la même série) :
|
||||||
|
{notes_list}
|
||||||
|
|
||||||
|
Ta tâche : associer CHAQUE note à UN titre officiel.
|
||||||
|
Règles IMPORTANTES :
|
||||||
|
1. Les notes listant les mêmes compositeurs (ordre ou sous-titres différents) → MÊME série
|
||||||
|
2. Les préfixes "(A) :", "(B) :", "(A') :", "(B') :" → séries PARALLÈLES DIFFÉRENTES
|
||||||
|
Ex: '(A) : "Magdalena"' → "Magdalena" ; '(B) : "Élémentaire"' → "Élémentaire, mon cher !"
|
||||||
|
3. Les annotations entre parenthèses (captation, présence de...) ne changent PAS la série
|
||||||
|
4. Les répétitions partielles (Cordes, Vents...) = même série que le Tutti
|
||||||
|
|
||||||
|
Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
|
||||||
|
{{
|
||||||
|
"matches": {{
|
||||||
|
"note exacte telle quelle": "Titre Officiel du Site",
|
||||||
|
...
|
||||||
|
}}
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
model = config['ollama']['cluster_model']
|
||||||
|
if log:
|
||||||
|
log(f"Identification des séries avec l'IA ({model})...")
|
||||||
|
|
||||||
|
content = _llm_call(prompt, config['ollama']['url'], model)
|
||||||
|
|
||||||
|
json_match = re.search(r'\{[\s\S]*\}', content)
|
||||||
|
if not json_match:
|
||||||
|
raise ValueError("Pas de JSON dans la réponse LLM")
|
||||||
|
|
||||||
|
raw = json_match.group()
|
||||||
|
try:
|
||||||
|
result = json.loads(raw).get('matches', {})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result = {}
|
||||||
|
for m in re.finditer(r'"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"', raw):
|
||||||
|
result[m.group(1)] = m.group(2)
|
||||||
|
|
||||||
|
with open(cache_file, 'w') as f:
|
||||||
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log(f"{len(result)} notes associées à des séries")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def match_notes_to_series(unique_notes: set, catalog: dict, config: dict,
|
||||||
|
cache_dir: Path, log: Callable = None,
|
||||||
|
force_series: bool = False) -> dict:
|
||||||
|
note_to_series = cluster_notes_global(
|
||||||
|
unique_notes, catalog, config, cache_dir, log, force_series
|
||||||
|
)
|
||||||
|
# Heuristique (A)/(B) pour les non-assignés
|
||||||
|
for note in unique_notes:
|
||||||
|
if note not in note_to_series and note.strip():
|
||||||
|
r = _apply_parallel_heuristic(note, catalog)
|
||||||
|
if r:
|
||||||
|
note_to_series[note] = r
|
||||||
|
|
||||||
|
# Retry local pour les notes restantes
|
||||||
|
still_missing = [n for n in unique_notes if n.strip() and n not in note_to_series]
|
||||||
|
if still_missing:
|
||||||
|
if log:
|
||||||
|
log(f"Retry pour {len(still_missing)} notes non assignées...")
|
||||||
|
titles_str = '\n'.join(f'- "{t}"' for t in sorted(catalog.keys()))
|
||||||
|
notes_str = '\n'.join(f'- {repr(n)}' for n in still_missing)
|
||||||
|
prompt = (
|
||||||
|
f"Associe ces notes à des titres officiels.\n"
|
||||||
|
f"Titres:\n{titles_str}\nNotes:\n{notes_str}\n"
|
||||||
|
f'Réponds UNIQUEMENT avec JSON: {{"matches": {{"note": "Titre"}}}}'
|
||||||
|
)
|
||||||
|
content = _llm_call(prompt, config['ollama']['url'], config['ollama']['local_model'])
|
||||||
|
j = re.search(r'\{[\s\S]*\}', content)
|
||||||
|
if j:
|
||||||
|
try:
|
||||||
|
note_to_series.update(json.loads(j.group()).get('matches', {}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return note_to_series
|
||||||
|
|
||||||
|
|
||||||
|
# ── Génération ICS ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_description(evt: dict, series_title: str, catalog: dict) -> str:
|
||||||
|
lines = []
|
||||||
|
if is_public_event(evt['titre']):
|
||||||
|
desc = catalog.get(series_title, {}).get('description', '')
|
||||||
|
lines.append(desc[:1500] if desc else f"Programme : {evt['note']}")
|
||||||
|
else:
|
||||||
|
if evt['note']:
|
||||||
|
lines.append(f"Œuvres : {evt['note']}")
|
||||||
|
lines.append(f"Type : {evt['titre']}")
|
||||||
|
if evt['dec']:
|
||||||
|
lines.append(f"Durée déclarée : {evt['dec']}")
|
||||||
|
if evt['voy']:
|
||||||
|
lines.append(f"Déplacement : {evt['voy']}h de trajet")
|
||||||
|
lines.append(f"Source : {evt['source_file']}")
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_ics_bytes(series_title: str, events: list, catalog: dict) -> bytes:
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add('prodid', '-//Opéra Orchestre National Montpellier//planning2ics//FR')
|
||||||
|
cal.add('version', '2.0')
|
||||||
|
cal.add('x-wr-calname', series_title)
|
||||||
|
cal.add('x-wr-timezone', 'Europe/Paris')
|
||||||
|
|
||||||
|
for evt in sorted(events, key=lambda e: (e['date'], e['start_time'])):
|
||||||
|
vevent = Event()
|
||||||
|
start_dt = datetime.combine(evt['date'], evt['start_time'])
|
||||||
|
vevent.add('dtstart', start_dt)
|
||||||
|
|
||||||
|
if evt['end_time']:
|
||||||
|
end_dt = datetime.combine(evt['date'], evt['end_time'])
|
||||||
|
else:
|
||||||
|
dec_m = re.match(r'(\d{1,2}):(\d{2})', evt['dec'])
|
||||||
|
duration = (
|
||||||
|
timedelta(hours=int(dec_m.group(1)), minutes=int(dec_m.group(2)))
|
||||||
|
if dec_m else timedelta(hours=2)
|
||||||
|
)
|
||||||
|
end_dt = start_dt + duration
|
||||||
|
|
||||||
|
vevent.add('dtend', end_dt)
|
||||||
|
vevent.add('summary', f"{evt['titre']} – {series_title}")
|
||||||
|
if evt['lieu']:
|
||||||
|
vevent.add('location', evt['lieu'])
|
||||||
|
vevent.add('description', _build_description(evt, series_title, catalog))
|
||||||
|
vevent.add('uid', str(uuid4()) + '@planning-orchestre')
|
||||||
|
cal.add_component(vevent)
|
||||||
|
|
||||||
|
return cal.to_ical()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Point d'entrée principal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
|
||||||
|
log: Callable = None) -> dict:
|
||||||
|
"""
|
||||||
|
Traite une liste de PDFs.
|
||||||
|
Retourne {series_title: {filename, bytes, event_count}}.
|
||||||
|
"""
|
||||||
|
cache_dir = data_dir / "cache"
|
||||||
|
|
||||||
|
# 1. Extraction
|
||||||
|
if log:
|
||||||
|
log(f"Extraction de {len(pdf_paths)} PDF(s)...")
|
||||||
|
all_events = []
|
||||||
|
for i, pdf_path in enumerate(pdf_paths):
|
||||||
|
if log:
|
||||||
|
log(f"Extraction {i+1}/{len(pdf_paths)} : {pdf_path.name}")
|
||||||
|
all_events.extend(extract_events_from_pdf(pdf_path))
|
||||||
|
if log:
|
||||||
|
log(f"{len(all_events)} événements extraits au total")
|
||||||
|
|
||||||
|
# 2. Catalogue site web
|
||||||
|
catalog = scrape_catalog(config, cache_dir, log)
|
||||||
|
|
||||||
|
# 3. Identification des séries
|
||||||
|
unique_notes = {e['note'] for e in all_events}
|
||||||
|
if log:
|
||||||
|
log(f"{len(unique_notes)} notes uniques à analyser...")
|
||||||
|
note_to_series = match_notes_to_series(unique_notes, catalog, config, cache_dir, log)
|
||||||
|
|
||||||
|
# 4. Groupement et génération ICS
|
||||||
|
series_events: dict[str, list] = {}
|
||||||
|
for evt in all_events:
|
||||||
|
s = note_to_series.get(evt['note'])
|
||||||
|
if s:
|
||||||
|
series_events.setdefault(s, []).append(evt)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log(f"Génération de {len(series_events)} fichiers ICS...")
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for series_title, events in series_events.items():
|
||||||
|
result[series_title] = {
|
||||||
|
'filename': sanitize_filename(series_title) + '.ics',
|
||||||
|
'bytes': _create_ics_bytes(series_title, events, catalog),
|
||||||
|
'event_count': len(events),
|
||||||
|
}
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log(f"Terminé : {len(result)} séries générées")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Déploiement Docker Swarm
|
||||||
|
# 1. Construire l'image : docker build -t planning2ics:latest ./webapp
|
||||||
|
# 2. Déployer : docker stack deploy -c webapp/docker-compose.yml planning2ics
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: planning2ics:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8000"
|
||||||
|
volumes:
|
||||||
|
# Données persistantes (cache, jobs, uploads)
|
||||||
|
- planning_data:/app/data
|
||||||
|
# Config montée en lecture seule — éditez config.json sur l'hôte
|
||||||
|
- ./config.json:/app/config.json:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
delay: 10s
|
||||||
|
failure_action: rollback
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
planning_data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.5
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
python-multipart==0.0.12
|
||||||
|
pdfplumber==0.11.4
|
||||||
|
icalendar==6.0.1
|
||||||
|
requests==2.32.3
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Helpers API ───────────────────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body = null) {
|
||||||
|
const isForm = body instanceof FormData;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: (!isForm && body) ? {'Content-Type': 'application/json'} : {},
|
||||||
|
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) { showPage('login'); return null; }
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || 'Erreur serveur');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
function showPage(name) {
|
||||||
|
document.getElementById('page-login').classList.toggle('hidden', name !== 'login');
|
||||||
|
document.getElementById('page-app').classList.toggle('hidden', name !== 'app');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function checkAuth() {
|
||||||
|
const me = await api('GET', '/api/auth/me');
|
||||||
|
if (!me) return;
|
||||||
|
document.getElementById('header-user').textContent = me.username;
|
||||||
|
showPage('app');
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-login').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errEl = document.getElementById('login-error');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
errEl.textContent = err.detail || 'Identifiants incorrects';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('header-user').textContent = data.username;
|
||||||
|
showPage('app');
|
||||||
|
loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
|
await api('POST', '/api/auth/logout');
|
||||||
|
showPage('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init globale ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadConfig(), loadHistory(), loadCacheStatus()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
async function loadConfig() {
|
||||||
|
const cfg = await api('GET', '/api/config');
|
||||||
|
if (!cfg) return;
|
||||||
|
document.getElementById('cfg-ollama-url').textContent = cfg.ollama_url;
|
||||||
|
document.getElementById('cfg-cluster-model').textContent = cfg.cluster_model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||||
|
let selectedFiles = [];
|
||||||
|
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
dropZone.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault(); dropZone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||||
|
dropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); dropZone.classList.remove('drag-over');
|
||||||
|
addFiles(Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.pdf')));
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', e => {
|
||||||
|
addFiles(Array.from(e.target.files));
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function addFiles(newFiles) {
|
||||||
|
const existing = new Set(selectedFiles.map(f => f.name));
|
||||||
|
newFiles.forEach(f => { if (!existing.has(f.name)) selectedFiles.push(f); });
|
||||||
|
renderFileList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList() {
|
||||||
|
const listEl = document.getElementById('file-list');
|
||||||
|
const actionsEl = document.getElementById('upload-actions');
|
||||||
|
if (!selectedFiles.length) {
|
||||||
|
listEl.classList.add('hidden');
|
||||||
|
actionsEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.classList.remove('hidden');
|
||||||
|
actionsEl.classList.remove('hidden');
|
||||||
|
listEl.innerHTML = selectedFiles.map((f, i) => `
|
||||||
|
<div class="file-item">
|
||||||
|
<span class="file-icon">📄</span>
|
||||||
|
<span class="file-name">${esc(f.name)}</span>
|
||||||
|
<button class="file-remove" data-i="${i}" title="Retirer">✕</button>
|
||||||
|
</div>`).join('');
|
||||||
|
listEl.querySelectorAll('.file-remove').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedFiles.splice(+btn.dataset.i, 1);
|
||||||
|
renderFileList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-files').addEventListener('click', () => {
|
||||||
|
selectedFiles = [];
|
||||||
|
renderFileList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Traitement ────────────────────────────────────────────────────────────────
|
||||||
|
let currentJobId = null;
|
||||||
|
|
||||||
|
document.getElementById('btn-process').addEventListener('click', async () => {
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-process');
|
||||||
|
const progSect = document.getElementById('section-progress');
|
||||||
|
const resSect = document.getElementById('section-results');
|
||||||
|
const fill = document.getElementById('progress-fill');
|
||||||
|
const log = document.getElementById('progress-log');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
resSect.classList.add('hidden');
|
||||||
|
progSect.classList.remove('hidden');
|
||||||
|
log.innerHTML = '';
|
||||||
|
fill.style.width = '3%';
|
||||||
|
fill.classList.add('running');
|
||||||
|
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const fd = new FormData();
|
||||||
|
selectedFiles.forEach(f => fd.append('files', f));
|
||||||
|
|
||||||
|
let jobId;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/process', {
|
||||||
|
method: 'POST', credentials: 'include', body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail);
|
||||||
|
jobId = (await res.json()).job_id;
|
||||||
|
currentJobId = jobId;
|
||||||
|
} catch (err) {
|
||||||
|
addLog('Erreur : ' + err.message, 'error');
|
||||||
|
fill.classList.remove('running');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
const es = new EventSource(`/api/progress/${jobId}`);
|
||||||
|
es.onmessage = e => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'ping') return;
|
||||||
|
|
||||||
|
if (msg.type === 'progress') {
|
||||||
|
addLog(msg.message);
|
||||||
|
steps++;
|
||||||
|
fill.style.width = Math.min(5 + steps * 7, 90) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'done') {
|
||||||
|
fill.classList.remove('running');
|
||||||
|
fill.style.width = '100%';
|
||||||
|
addLog('Traitement termin\u00e9 avec succ\u00e8s', 'done');
|
||||||
|
es.close();
|
||||||
|
renderResults(jobId, msg.series);
|
||||||
|
loadHistory();
|
||||||
|
loadCacheStatus();
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
fill.classList.remove('running');
|
||||||
|
addLog('Erreur : ' + msg.message, 'error');
|
||||||
|
es.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => { es.close(); btn.disabled = false; };
|
||||||
|
});
|
||||||
|
|
||||||
|
function addLog(msg, cls = '') {
|
||||||
|
const log = document.getElementById('progress-log');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'log-line' + (cls ? ' ' + cls : '');
|
||||||
|
div.textContent = msg;
|
||||||
|
log.appendChild(div);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── R\u00e9sultats ──────────────────────────────────────────────────────────────────
|
||||||
|
function renderResults(jobId, series) {
|
||||||
|
const sect = document.getElementById('section-results');
|
||||||
|
const grid = document.getElementById('series-grid');
|
||||||
|
sect.classList.remove('hidden');
|
||||||
|
|
||||||
|
grid.innerHTML = series.map(s => `
|
||||||
|
<div class="series-card">
|
||||||
|
<h3>${esc(s.name)}</h3>
|
||||||
|
<span class="series-count">${s.event_count} \u00e9v\u00e9nement${s.event_count > 1 ? 's' : ''}</span>
|
||||||
|
<a href="/api/download/${esc(jobId)}/${esc(s.filename)}"
|
||||||
|
class="btn btn-secondary" download="${esc(s.filename)}">
|
||||||
|
⇓ T\u00e9l\u00e9charger ICS
|
||||||
|
</a>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// Bouton "tout t\u00e9l\u00e9charger" : t\u00e9l\u00e9charge l'un apr\u00e8s l'autre
|
||||||
|
document.getElementById('btn-dl-all').onclick = () => {
|
||||||
|
series.forEach((s, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/download/${jobId}/${encodeURIComponent(s.filename)}`;
|
||||||
|
a.download = s.filename;
|
||||||
|
a.click();
|
||||||
|
}, i * 400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
sect.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Historique ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadHistory() {
|
||||||
|
const jobs = await api('GET', '/api/jobs');
|
||||||
|
if (!jobs) return;
|
||||||
|
const listEl = document.getElementById('history-list');
|
||||||
|
if (!jobs.length) {
|
||||||
|
listEl.innerHTML = '<p class="empty-msg">Aucun traitement pr\u00e9c\u00e9dent</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = jobs.slice(0, 15).map(j => `
|
||||||
|
<div class="history-item" data-jobid="${esc(j.job_id)}">
|
||||||
|
<div class="hist-header">
|
||||||
|
<span class="hist-title">${j.series ? j.series.length : '?'} s\u00e9rie${j.series?.length > 1 ? 's' : ''}</span>
|
||||||
|
<span class="hist-date">${fmtDate(j.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hist-files">${esc((j.pdf_names || []).join(', '))}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.history-item').forEach(item =>
|
||||||
|
item.addEventListener('click', async () => {
|
||||||
|
const job = await api('GET', `/api/jobs/${item.dataset.jobid}`);
|
||||||
|
if (job?.series) renderResults(job.job_id, job.series);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function loadCacheStatus() {
|
||||||
|
const status = await api('GET', '/api/cache/status');
|
||||||
|
if (!status) return;
|
||||||
|
const set = (id, ok) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = ok ? '\u2713 En cache' : '\u2715 Non mis en cache';
|
||||||
|
el.className = 'setting-value ' + (ok ? 'ok' : 'nok');
|
||||||
|
};
|
||||||
|
set('cache-website', status.website_cached);
|
||||||
|
set('cache-series', status.series_cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-cache').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Vider le cache ?\nLe prochain traitement re-scrapera le site et re-classifiera les s\u00e9ries avec l\'IA.')) return;
|
||||||
|
const result = await api('DELETE', '/api/cache');
|
||||||
|
if (!result) return;
|
||||||
|
alert(result.deleted.length
|
||||||
|
? 'Cache vid\u00e9 : ' + result.deleted.join(', ')
|
||||||
|
: 'Cache d\u00e9j\u00e0 vide');
|
||||||
|
loadCacheStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── D\u00e9marrage ──────────────────────────────────────────────────────────────────
|
||||||
|
checkAuth();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Planning → ICS</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Page Login ─────────────────────────────────────────── -->
|
||||||
|
<div id="page-login" class="page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">♫</div>
|
||||||
|
<h1>Planning → ICS</h1>
|
||||||
|
<p class="subtitle">Opéra Orchestre National Montpellier</p>
|
||||||
|
<form id="form-login">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Utilisateur</label>
|
||||||
|
<input type="text" id="username" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="error-msg hidden"></div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Page App ───────────────────────────────────────────── -->
|
||||||
|
<div id="page-app" class="page hidden">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="header-icon">♫</span>
|
||||||
|
<span class="header-title">Planning → ICS</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="header-user"></span>
|
||||||
|
<button id="btn-logout" class="btn btn-ghost-white">Déconnexion</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Importer des plannings PDF</h2>
|
||||||
|
<div id="drop-zone" class="drop-zone">
|
||||||
|
<div class="drop-content">
|
||||||
|
<div class="drop-icon">📄</div>
|
||||||
|
<p>Glissez vos PDFs ici</p>
|
||||||
|
<p class="drop-hint">ou</p>
|
||||||
|
<label for="file-input" class="btn btn-secondary">Choisir des fichiers</label>
|
||||||
|
<input type="file" id="file-input" accept=".pdf" multiple hidden>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="file-list" class="file-list hidden"></div>
|
||||||
|
<div id="upload-actions" class="upload-actions hidden">
|
||||||
|
<button id="btn-process" class="btn btn-primary">Convertir en ICS</button>
|
||||||
|
<button id="btn-clear-files" class="btn btn-ghost">Effacer</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Progression -->
|
||||||
|
<section id="section-progress" class="card hidden">
|
||||||
|
<h2>Traitement en cours…</h2>
|
||||||
|
<div class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
|
||||||
|
<div id="progress-log" class="progress-log"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<section id="section-results" class="card hidden">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Séries générées</h2>
|
||||||
|
<button id="btn-dl-all" class="btn btn-secondary btn-sm">Télécharger tout</button>
|
||||||
|
</div>
|
||||||
|
<div id="series-grid" class="series-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Historique -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Historique</h2>
|
||||||
|
<div id="history-list"><p class="empty-msg">Aucun traitement précédent</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Paramètres -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Paramètres</h2>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">Serveur Ollama</div>
|
||||||
|
<div id="cfg-ollama-url" class="setting-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">Modèle clustering</div>
|
||||||
|
<div id="cfg-cluster-model" class="setting-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">Cache site web</div>
|
||||||
|
<div id="cache-website" class="setting-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">Cache séries LLM</div>
|
||||||
|
<div id="cache-series" class="setting-value">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-clear-cache" class="btn btn-danger">Vider le cache</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/* ── Variables ───────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f5f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e1e4e8;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-sub: #6b7280;
|
||||||
|
--primary: #1a237e;
|
||||||
|
--primary-h: #283593;
|
||||||
|
--accent: #3949ab;
|
||||||
|
--success: #1b5e20;
|
||||||
|
--success-bg:#e8f5e9;
|
||||||
|
--danger: #b71c1c;
|
||||||
|
--danger-bg: #ffebee;
|
||||||
|
--shadow: 0 1px 4px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ───────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); min-height: 100vh; font-size: 15px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
padding: 9px 18px; border-radius: var(--radius-sm);
|
||||||
|
border: none; cursor: pointer; font-size: 14px; font-weight: 600;
|
||||||
|
transition: opacity .15s, transform .1s, background .15s;
|
||||||
|
white-space: nowrap; text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: .88; }
|
||||||
|
.btn:active { transform: scale(.97); }
|
||||||
|
.btn:disabled{ opacity: .45; cursor: not-allowed; pointer-events: none; }
|
||||||
|
.btn-sm { padding: 6px 13px; font-size: 12px; }
|
||||||
|
.btn-full { width: 100%; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--primary-h); opacity: 1; }
|
||||||
|
.btn-secondary { background: var(--surface); color: var(--primary);
|
||||||
|
border: 1.5px solid var(--primary); }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text-sub); }
|
||||||
|
.btn-ghost:hover { background: var(--border); }
|
||||||
|
.btn-ghost-white { background: transparent; color: rgba(255,255,255,.85);
|
||||||
|
border: 1px solid rgba(255,255,255,.3); }
|
||||||
|
.btn-ghost-white:hover { background: rgba(255,255,255,.15); opacity: 1; }
|
||||||
|
.btn-danger { background: var(--danger-bg); color: var(--danger);
|
||||||
|
border: 1.5px solid #ef9a9a; }
|
||||||
|
|
||||||
|
/* ── Login ───────────────────────────────────────────────── */
|
||||||
|
#page-login {
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: linear-gradient(145deg, #0d1757 0%, #1a237e 60%, #283593 100%);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface); border-radius: var(--radius);
|
||||||
|
padding: 44px 36px; width: 100%; max-width: 380px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0,0,0,.35); text-align: center;
|
||||||
|
}
|
||||||
|
.login-logo { font-size: 44px; margin-bottom: 14px; }
|
||||||
|
.login-card h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: var(--text-sub); font-size: 13px; margin-bottom: 32px; }
|
||||||
|
|
||||||
|
.field { text-align: left; margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; font-weight: 700;
|
||||||
|
color: var(--text-sub); margin-bottom: 5px; text-transform: uppercase;
|
||||||
|
letter-spacing: .4px; }
|
||||||
|
.field input {
|
||||||
|
width: 100%; padding: 11px 13px;
|
||||||
|
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
font-size: 15px; transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.error-msg { background: var(--danger-bg); color: var(--danger);
|
||||||
|
padding: 10px 14px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; margin-bottom: 12px; text-align: left; }
|
||||||
|
.login-card .btn { margin-top: 6px; }
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────── */
|
||||||
|
header {
|
||||||
|
background: var(--primary); color: #fff;
|
||||||
|
padding: 0 20px; height: 56px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
position: sticky; top: 0; z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.header-icon { font-size: 20px; }
|
||||||
|
.header-title { font-size: 17px; font-weight: 700; letter-spacing: -.2px; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
#header-user { font-size: 13px; opacity: .75; }
|
||||||
|
|
||||||
|
/* ── Main layout ─────────────────────────────────────────── */
|
||||||
|
main { max-width: 820px; margin: 0 auto; padding: 20px 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
/* ── Cards ───────────────────────────────────────────────── */
|
||||||
|
.card { background: var(--surface); border-radius: var(--radius);
|
||||||
|
padding: 22px; box-shadow: var(--shadow); }
|
||||||
|
.card h2 { font-size: 16px; font-weight: 700; margin-bottom: 18px; }
|
||||||
|
.section-header { display: flex; align-items: center;
|
||||||
|
justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.section-header h2 { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ── Drop zone ───────────────────────────────────────────── */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border); border-radius: var(--radius);
|
||||||
|
padding: 36px 20px; text-align: center; cursor: pointer;
|
||||||
|
background: #fafbff; transition: border-color .2s, background .2s;
|
||||||
|
}
|
||||||
|
.drop-zone.drag-over { border-color: var(--accent); background: #eef0fb; }
|
||||||
|
.drop-content { pointer-events: none; }
|
||||||
|
.drop-icon { font-size: 32px; display: block; margin-bottom: 10px; }
|
||||||
|
.drop-zone p { color: var(--text-sub); font-size: 13px; margin-bottom: 6px; }
|
||||||
|
.drop-hint { font-size: 11px; margin: 4px 0 12px; }
|
||||||
|
|
||||||
|
/* ── File list ───────────────────────────────────────────── */
|
||||||
|
.file-list { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.file-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 13px; background: #f0f1f8; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.file-icon { font-size: 16px; flex-shrink: 0; }
|
||||||
|
.file-name { flex: 1; font-weight: 500; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-remove { border: none; background: none; cursor: pointer;
|
||||||
|
color: var(--text-sub); font-size: 14px; padding: 0; flex-shrink: 0; }
|
||||||
|
.upload-actions { margin-top: 14px; display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ── Progress ────────────────────────────────────────────── */
|
||||||
|
.progress-bar { height: 7px; background: var(--border); border-radius: 4px;
|
||||||
|
overflow: hidden; margin-bottom: 14px; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px;
|
||||||
|
width: 0%; transition: width .4s ease; }
|
||||||
|
.progress-fill.running { animation: shimmer 1.8s infinite; }
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: .65; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.progress-log {
|
||||||
|
background: #f7f8fc; border-radius: var(--radius-sm);
|
||||||
|
padding: 11px 14px; max-height: 200px; overflow-y: auto;
|
||||||
|
font-size: 12px; font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
color: var(--text-sub); display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.log-line { line-height: 1.45; }
|
||||||
|
.log-line.done { color: var(--success); font-weight: 700; }
|
||||||
|
.log-line.error { color: var(--danger); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Series grid ─────────────────────────────────────────── */
|
||||||
|
.series-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 11px; }
|
||||||
|
.series-card {
|
||||||
|
background: #f7f8ff; border: 1.5px solid #c5cae9;
|
||||||
|
border-radius: var(--radius-sm); padding: 15px;
|
||||||
|
display: flex; flex-direction: column; gap: 7px;
|
||||||
|
}
|
||||||
|
.series-card h3 { font-size: 13px; font-weight: 700; color: var(--primary);
|
||||||
|
line-height: 1.35; }
|
||||||
|
.series-count { font-size: 11px; color: var(--text-sub); }
|
||||||
|
.series-card .btn { margin-top: auto; font-size: 12px; padding: 7px 12px; }
|
||||||
|
|
||||||
|
/* ── History ─────────────────────────────────────────────── */
|
||||||
|
#history-list { display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.history-item {
|
||||||
|
padding: 13px 15px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.history-item:hover { background: #f4f5ff; }
|
||||||
|
.hist-header { display: flex; justify-content: space-between;
|
||||||
|
align-items: baseline; margin-bottom: 4px; }
|
||||||
|
.hist-title { font-size: 13px; font-weight: 700; }
|
||||||
|
.hist-date { font-size: 11px; color: var(--text-sub); }
|
||||||
|
.hist-files { font-size: 11px; color: var(--text-sub); }
|
||||||
|
.empty-msg { color: var(--text-sub); font-size: 13px;
|
||||||
|
text-align: center; padding: 18px 0; }
|
||||||
|
|
||||||
|
/* ── Settings ────────────────────────────────────────────── */
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
|
||||||
|
.setting-item { padding: 11px 13px; background: #f7f8fc; border-radius: var(--radius-sm); }
|
||||||
|
.setting-label { font-size: 11px; font-weight: 700; color: var(--text-sub);
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 3px; }
|
||||||
|
.setting-value { font-size: 12px; font-family: monospace; word-break: break-all; }
|
||||||
|
.setting-value.ok { color: var(--success); }
|
||||||
|
.setting-value.nok { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────── */
|
||||||
|
@media (max-width: 580px) {
|
||||||
|
.login-card { padding: 32px 22px; }
|
||||||
|
.card { padding: 16px; }
|
||||||
|
.series-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.settings-grid { grid-template-columns: 1fr; }
|
||||||
|
.header-title { font-size: 14px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.series-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user