diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 42a8a50..90938dc 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,10 +1,15 @@ import asyncio import os +import shutil +import tarfile +import tempfile from datetime import datetime from pathlib import Path from urllib.parse import urlparse -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -50,22 +55,14 @@ async def get_stats(session: AsyncSession = Depends(get_session)): } -def _pg_env() -> dict: +def _pg_env() -> tuple[dict, object]: url = urlparse(settings.database_url.replace("+asyncpg", "")) env = os.environ.copy() env["PGPASSWORD"] = url.password or "" return env, url -@router.post("/backup") -async def create_backup(): - backup_dir = settings.backup_path - backup_dir.mkdir(parents=True, exist_ok=True) - - timestamp = datetime.now().strftime("%Y-%m-%d_%H%M") - filename = f"homehub_{timestamp}.dump" - filepath = backup_dir / filename - +async def _pg_dump_to(path: Path) -> None: env, url = _pg_env() proc = await asyncio.create_subprocess_exec( "pg_dump", "-Fc", @@ -73,48 +70,16 @@ async def create_backup(): "-p", str(url.port or 5432), "-U", url.username or "homehub", "-d", (url.path or "/homehub").lstrip("/"), - "-f", str(filepath), + "-f", str(path), env=env, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise HTTPException(500, f"Échec du backup : {stderr.decode()}") - - stat = filepath.stat() - return { - "filename": filename, - "size": stat.st_size, - "created_at": datetime.now().isoformat(), - } + raise HTTPException(500, f"Échec du pg_dump : {stderr.decode()}") -@router.get("/backups") -async def list_backups(): - backup_dir = settings.backup_path - if not backup_dir.exists(): - return [] - files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True) - return [ - { - "filename": f.name, - "size": f.stat().st_size, - "created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(), - } - for f in files - ] - - -@router.post("/restore/{filename}") -async def restore_backup(filename: str): - # Sécurité : interdit les chemins relatifs - if "/" in filename or ".." in filename or not filename.endswith(".dump"): - raise HTTPException(400, "Nom de fichier invalide") - - filepath = settings.backup_path / filename - if not filepath.exists(): - raise HTTPException(404, "Fichier introuvable") - +async def _pg_restore_from(path: Path) -> None: env, url = _pg_env() proc = await asyncio.create_subprocess_exec( "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", @@ -122,7 +87,7 @@ async def restore_backup(filename: str): "-p", str(url.port or 5432), "-U", url.username or "homehub", "-d", (url.path or "/homehub").lstrip("/"), - str(filepath), + str(path), env=env, stderr=asyncio.subprocess.PIPE, ) @@ -131,4 +96,86 @@ async def restore_backup(filename: str): if proc.returncode not in (0, 1): raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}") - return {"message": "Restauration réussie"} + +@router.post("/backup") +async def download_backup(): + """Génère une archive .tar.gz contenant DB + médias, streamée au navigateur.""" + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M") + archive_name = f"homehub_{timestamp}.tar.gz" + + tmpdir = Path(tempfile.mkdtemp(prefix="homehub_backup_")) + dump_path = tmpdir / "db.dump" + archive_path = tmpdir / archive_name + + await _pg_dump_to(dump_path) + + def _build_archive() -> None: + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(dump_path, arcname="db.dump") + uploads = settings.upload_path + if uploads.exists(): + tar.add(uploads, arcname="uploads") + + await asyncio.to_thread(_build_archive) + + def _cleanup() -> None: + shutil.rmtree(tmpdir, ignore_errors=True) + + return FileResponse( + archive_path, + media_type="application/gzip", + filename=archive_name, + background=BackgroundTask(_cleanup), + ) + + +@router.post("/restore") +async def upload_and_restore(file: UploadFile = File(...)): + """Restaure depuis une archive .tar.gz uploadée (DB + médias).""" + if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")): + raise HTTPException(400, "Format attendu : .tar.gz") + + tmpdir = Path(tempfile.mkdtemp(prefix="homehub_restore_")) + try: + archive_path = tmpdir / "upload.tar.gz" + with archive_path.open("wb") as f: + while chunk := await file.read(1024 * 1024): + f.write(chunk) + + extract_dir = tmpdir / "extract" + extract_dir.mkdir() + + def _extract() -> None: + with tarfile.open(archive_path, "r:gz") as tar: + # Sécurité : refuser les chemins absolus ou contenant .. + for member in tar.getmembers(): + if member.name.startswith("/") or ".." in Path(member.name).parts: + raise HTTPException(400, f"Chemin invalide dans l'archive : {member.name}") + tar.extractall(extract_dir, filter="data") + + await asyncio.to_thread(_extract) + + dump_path = extract_dir / "db.dump" + if not dump_path.exists(): + raise HTTPException(400, "Archive invalide : db.dump introuvable") + await _pg_restore_from(dump_path) + + uploads_src = extract_dir / "uploads" + if uploads_src.exists(): + uploads_dst = settings.upload_path + uploads_dst.mkdir(parents=True, exist_ok=True) + + def _sync_media() -> None: + for item in uploads_src.rglob("*"): + if not item.is_file(): + continue + rel = item.relative_to(uploads_src) + dest = uploads_dst / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, dest) + + await asyncio.to_thread(_sync_media) + + return {"message": "Restauration réussie"} + finally: + shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/frontend/package.json b/frontend/package.json index aa8e389..e67171f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "homehub-frontend", "private": true, - "version": "0.5.10", + "version": "0.5.11", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index ef1521a..3b3f527 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -18,31 +18,51 @@ export async function fetchStats(): Promise { return res.json() as Promise } -export interface BackupFile { - filename: string - size: number - created_at: string -} - -export async function fetchBackups(): Promise { - const res = await fetch('/api/admin/backups') - if (!res.ok) throw new Error('Erreur chargement sauvegardes') - return res.json() as Promise -} - -export async function createBackup(): Promise { +export async function downloadBackup(): Promise { const res = await fetch('/api/admin/backup', { method: 'POST' }) if (!res.ok) { const err = await res.json().catch(() => ({})) as { detail?: string } throw new Error(err.detail ?? 'Erreur lors du backup') } - return res.json() as Promise + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') ?? '' + const match = /filename="?([^";]+)"?/.exec(disposition) + const filename = match?.[1] ?? `homehub_${new Date().toISOString().slice(0, 10)}.tar.gz` + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + return filename } -export async function restoreBackup(filename: string): Promise { - const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) as { detail?: string } - throw new Error(err.detail ?? 'Erreur lors de la restauration') - } +export async function uploadAndRestore(file: File, onProgress?: (pct: number) => void): Promise { + await new Promise((resolve, reject) => { + const form = new FormData() + form.append('file', file) + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/admin/restore') + if (onProgress) { + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) onProgress(Math.round((ev.loaded / ev.total) * 100)) + } + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve() + else { + try { + const err = JSON.parse(xhr.responseText) as { detail?: string } + reject(new Error(err.detail ?? `Erreur ${xhr.status}`)) + } catch { + reject(new Error(`Erreur ${xhr.status}`)) + } + } + } + xhr.onerror = () => reject(new Error('Erreur réseau')) + xhr.send(form) + }) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 3418a00..459f39a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -15,9 +15,14 @@ input, textarea, select { user-select: text; } +html, body { + margin: 0; + overflow-x: hidden; + max-width: 100vw; +} + body { font-family: var(--font-ui); background-color: var(--bg-1); color: var(--ink-1); - margin: 0; } diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 888afc7..d77ac3e 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react' +import { useRef, useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useTheme, type ThemeMode } from '../contexts/ThemeContext' -import { fetchBackups, createBackup, restoreBackup, fetchStats, type BackupFile, type AppStats } from '../api/admin' +import { downloadBackup, uploadAndRestore, fetchStats, type AppStats } from '../api/admin' const sectionStyle: React.CSSProperties = { background: 'var(--bg-3)', @@ -39,30 +39,28 @@ function formatSize(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} Mo` } -function formatDate(iso: string): string { - return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' }) -} - export default function ConfigPage() { const navigate = useNavigate() const { theme, setTheme, fontScale, setFontScale } = useTheme() const [stats, setStats] = useState(null) - const [backups, setBackups] = useState([]) const [backupLoading, setBackupLoading] = useState(false) - const [restoring, setRestoring] = useState(null) + const [restoring, setRestoring] = useState(false) + const [restoreProgress, setRestoreProgress] = useState(0) const [backupError, setBackupError] = useState(null) + const [backupInfo, setBackupInfo] = useState(null) + const fileRef = useRef(null) useEffect(() => { fetchStats().then(setStats).catch(() => null) - fetchBackups().then(setBackups).catch(() => setBackups([])) }, []) async function handleCreateBackup() { setBackupLoading(true) setBackupError(null) + setBackupInfo(null) try { - const b = await createBackup() - setBackups(prev => [b, ...prev]) + const filename = await downloadBackup() + setBackupInfo(`Téléchargé : ${filename}`) } catch (e) { setBackupError((e as Error).message) } finally { @@ -70,16 +68,22 @@ export default function ConfigPage() { } } - async function handleRestore(filename: string) { - if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return - setRestoring(filename) + async function handleRestoreFile(file: File) { + if (!confirm(`Restaurer depuis "${file.name}" ? La base actuelle (BDD + médias) sera remplacée.`)) return + setRestoring(true) + setRestoreProgress(0) setBackupError(null) + setBackupInfo(null) try { - await restoreBackup(filename) + await uploadAndRestore(file, pct => setRestoreProgress(pct)) + setBackupInfo('Restauration réussie') + fetchStats().then(setStats).catch(() => null) } catch (e) { setBackupError((e as Error).message) } finally { - setRestoring(null) + setRestoring(false) + setRestoreProgress(0) + if (fileRef.current) fileRef.current.value = '' } } @@ -189,10 +193,11 @@ export default function ConfigPage() { {/* Sauvegarde & Restauration */}
-
Base de données
+
Sauvegarde complète (BDD + médias)
+ + { + const f = e.target.files?.[0] + if (f) void handleRestoreFile(f) + }} + /> + + + {restoring && ( +
+
+
+ )} + {backupError && (
{backupError}
)} - {backups.length > 0 && ( -
-
Sauvegardes disponibles
- {backups.map(b => ( -
- -
-
- {b.filename} -
-
- {formatDate(b.created_at)} — {formatSize(b.size)} -
-
- -
- ))} + {backupInfo && ( +
+ {backupInfo}
)} - {backups.length === 0 && !backupLoading && ( -
- Aucune sauvegarde disponible -
- )} +
+ L'archive contient le dump PostgreSQL et tout le dossier uploads/ (photos, audio, vidéos). À restaurer sur une instance compatible. +
{/* Taille du texte */} diff --git a/frontend/src/pages/NotesPage.tsx b/frontend/src/pages/NotesPage.tsx index 477eab0..64563a7 100644 --- a/frontend/src/pages/NotesPage.tsx +++ b/frontend/src/pages/NotesPage.tsx @@ -326,10 +326,10 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, // ─── COLLAPSED ─────────────────────────────────────────────────────────────── if (state === 'collapsed') { return ( -
+
-
- +
+ {note.title || note.content.slice(0, 60).replace(/\n/g, ' ')} @@ -345,17 +345,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, // ─── SEMI (défaut) ─────────────────────────────────────────────────────────── if (state === 'semi') { return ( -
+
-
+
{note.title && ( -
+
{note.title}
)}
{note.content}
@@ -370,18 +371,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, // ─── EXPANDED ──────────────────────────────────────────────────────────────── return ( -
+
{note.title && ( -
+
{note.title}
)}
{toggleBtn}
-
{renderMarkdown(note.content)}
+
{renderMarkdown(note.content)}
{mediaSection} {metaLine} {actionButtons} diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 9522118..36b7c6e 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -30,6 +30,24 @@ const inputStyle: React.CSSProperties = { const noSelect: React.CSSProperties = { userSelect: 'none' } +function isoWeek(d: Date): { week: number; year: number } { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) + date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7)) + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)) + const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) + return { week, year: date.getUTCFullYear() } +} + +function isListOutdated(name: string | null | undefined): boolean { + if (!name) return false + const m = /^S(\d{1,2})\s+(\d{4})$/.exec(name) + if (!m) return false + const listWeek = parseInt(m[1], 10) + const listYear = parseInt(m[2], 10) + const { week, year } = isoWeek(new Date()) + return year > listYear || (year === listYear && week > listWeek) +} + function QtyControls({ qty, onDecrement, onIncrement }: { qty: number; onDecrement: () => void; onIncrement: () => void }) { const btnBase: React.CSSProperties = { width: 32, height: 32, borderRadius: 8, border: 'none', @@ -334,10 +352,13 @@ export default function ShoppingPage() { } } + const [showFinishConfirm, setShowFinishConfirm] = useState(false) + async function handleFinish() { if (!currentList) return try { await finishShopping(currentList.id) + setShowFinishConfirm(false) void loadData() } catch { setError('Erreur lors de la finalisation') @@ -418,6 +439,21 @@ export default function ShoppingPage() { ...noSelect, }} >Boutiques + {hasCurrentList && ( + + )}
{/* ── Erreur ── */} @@ -496,10 +532,27 @@ export default function ShoppingPage() { padding: '8px 16px', background: 'var(--bg-3)', borderBottom: '1px solid var(--bg-4)', + flexWrap: 'wrap', }}> - + {checkedItems.length}/{currentList.item_count} cochés + {isListOutdated(currentList.name) && ( + + + semaine dépassée + + )} +
{pastLists.length > 0 && ( + > + Clôturer la semaine +
{/* Articles non cochés */} @@ -801,6 +857,35 @@ export default function ShoppingPage() { onStoresChanged={() => void loadData()} /> )} + + {showFinishConfirm && currentList && ( + setShowFinishConfirm(false)} width={420}> +
+

+ La liste {currentList.name ?? 'en cours'} va être archivée. +

+ {uncheckedItems.length > 0 ? ( +

+ {uncheckedItems.length} article{uncheckedItems.length > 1 ? 's' : ''} non coché{uncheckedItems.length > 1 ? 's' : ''} {uncheckedItems.length > 1 ? 'seront reportés' : 'sera reporté'} dans la nouvelle liste de la semaine en cours. +

+ ) : ( +

+ Tous les articles sont cochés. Une nouvelle liste vide sera créée pour la semaine en cours. +

+ )} +
+ + +
+
+
+ )}
) } diff --git a/frontend/src/pages/TodosPage.tsx b/frontend/src/pages/TodosPage.tsx index 8690bed..ca2a364 100644 --- a/frontend/src/pages/TodosPage.tsx +++ b/frontend/src/pages/TodosPage.tsx @@ -149,6 +149,13 @@ export default function TodosPage() { +
{error && (