feat: export Markdown notes (ARQ/Redis) + backup/restore BDD — v0.5.2

- Volume data/ (bind mount ./data) remplace le volume uploads nommé
  data/notes/ → .md auto-générés, data/uploads/ → médias, data/backup/ → dumps
- Service Redis (redis:7-alpine) + worker ARQ (backend-worker)
- notes_markdown.py : frontmatter YAML + contenu + pièces jointes (liens relatifs)
  Nom : YYYY-MM-DD_slug-titre_shortid.md, rotation si titre modifié
- api/notes.py : publie export_note_markdown / remove_note_markdown sur Redis
  après chaque create / update / delete / add_attachment / delete_attachment
- api/admin.py : POST /backup, GET /backups, POST /restore/{filename} (pg_dump/pg_restore)
- Backend Dockerfile : postgresql-client ; requirements : arq==0.26.1
- ConfigPage : section "Base de données" avec sauvegarde + liste + restauration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:33:29 +02:00
parent 3d77ed6cc7
commit be0c8bceb6
18 changed files with 482 additions and 11 deletions
+4
View File
@@ -8,3 +8,7 @@ node_modules/
dist/ dist/
screenshot.png screenshot.png
*.png *.png
# Données utilisateur — ignorées sauf la structure
data/notes/*.md
data/backup/*.dump
data/uploads/
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \ libpq-dev gcc postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import os
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException
from app.core.config import settings
router = APIRouter()
def _pg_env() -> dict:
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
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_dump", "-Fc",
"-h", url.hostname or "db",
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
"-f", str(filepath),
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(),
}
@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")
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges",
"-h", url.hostname or "db",
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
str(filepath),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
# pg_restore retourne 1 pour des warnings non-fatals (DROP IF EXISTS sur objets manquants)
if proc.returncode not in (0, 1):
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
return {"message": "Restauration réussie"}
+6
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.database import get_session from app.core.database import get_session
from app.core.redis import enqueue
from app.models.notes import NoteItem, NoteAttachment from app.models.notes import NoteItem, NoteAttachment
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES
@@ -75,6 +76,7 @@ async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_s
await session.refresh(note, ["attachments"]) await session.refresh(note, ["attachments"])
await session.commit() await session.commit()
await session.refresh(note, ["attachments"]) await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note.id))
return note return note
@@ -97,6 +99,7 @@ async def update_note(
setattr(note, field, value) setattr(note, field, value)
await session.commit() await session.commit()
await session.refresh(note, ["attachments"]) await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note.id))
return note return note
@@ -107,6 +110,7 @@ async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_se
raise HTTPException(404, "Note introuvable") raise HTTPException(404, "Note introuvable")
await session.delete(note) await session.delete(note)
await session.commit() await session.commit()
await enqueue("remove_note_markdown", str(note_id))
return Response(status_code=204) return Response(status_code=204)
@@ -145,6 +149,7 @@ async def add_attachment(
session.add(att) session.add(att)
await session.commit() await session.commit()
await session.refresh(note, ["attachments"]) await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note_id))
return note return note
@@ -170,4 +175,5 @@ async def delete_attachment(
) )
await session.delete(att) await session.delete(att)
await session.commit() await session.commit()
await enqueue("export_note_markdown", str(note_id))
return Response(status_code=204) return Response(status_code=204)
+11 -1
View File
@@ -6,7 +6,9 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub" database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub"
upload_dir: str = "/uploads" upload_dir: str = "/data/uploads"
data_dir: str = "/data"
redis_url: str = "redis://redis:6379"
cors_origins: str = "http://localhost:3000" cors_origins: str = "http://localhost:3000"
@property @property
@@ -17,5 +19,13 @@ class Settings(BaseSettings):
def upload_path(self) -> Path: def upload_path(self) -> Path:
return Path(self.upload_dir) return Path(self.upload_dir)
@property
def notes_md_path(self) -> Path:
return Path(self.data_dir) / "notes"
@property
def backup_path(self) -> Path:
return Path(self.data_dir) / "backup"
settings = Settings() settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
import logging
from arq import create_pool
from arq.connections import ArqRedis, RedisSettings
from app.core.config import settings
_pool: ArqRedis | None = None
logger = logging.getLogger(__name__)
async def init_redis() -> None:
global _pool
try:
_pool = await create_pool(RedisSettings.from_dsn(settings.redis_url))
logger.info("Redis pool initialisé")
except Exception as e:
logger.warning(f"Redis indisponible, export Markdown désactivé : {e}")
async def close_redis() -> None:
global _pool
if _pool:
await _pool.close()
_pool = None
async def enqueue(job_name: str, *args) -> None:
if _pool is None:
return
try:
await _pool.enqueue_job(job_name, *args)
except Exception as e:
logger.warning(f"Redis enqueue échoué ({job_name}) : {e}")
+9
View File
@@ -1,20 +1,28 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.admin import router as admin_router
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.media import router as media_router from app.api.media import router as media_router
from app.api.notes import router as notes_router from app.api.notes import router as notes_router
from app.api.todos import router as todos_router from app.api.todos import router as todos_router
from app.api.shopping import router as shopping_router from app.api.shopping import router as shopping_router
from app.core.config import settings from app.core.config import settings
from app.core.redis import init_redis, close_redis
from app.data.seed import run_seed from app.data.seed import run_seed
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Crée les dossiers data/ au démarrage
for subdir in ("uploads", "notes", "backup"):
Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True)
await run_seed() await run_seed()
await init_redis()
yield yield
await close_redis()
app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan) app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan)
@@ -28,6 +36,7 @@ app.add_middleware(
) )
app.include_router(health_router, prefix="/api") app.include_router(health_router, prefix="/api")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(media_router, prefix="/api/media") app.include_router(media_router, prefix="/api/media")
app.include_router(notes_router, prefix="/api/notes") app.include_router(notes_router, prefix="/api/notes")
app.include_router(todos_router, prefix="/api/todos") app.include_router(todos_router, prefix="/api/todos")
+79
View File
@@ -0,0 +1,79 @@
import re
import unicodedata
from pathlib import Path
from app.core.config import settings
from app.models.notes import NoteItem
def _slugify(text: str, max_len: int = 50) -> str:
text = unicodedata.normalize("NFD", text)
text = "".join(c for c in text if unicodedata.category(c) != "Mn")
text = text.lower()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_-]+", "-", text)
return text.strip("-")[:max_len]
def _note_filename(note: NoteItem) -> str:
date = note.created_at.strftime("%Y-%m-%d")
raw = note.title or note.content[:40]
slug = _slugify(raw) or "sans-titre"
short_id = str(note.id)[:8]
return f"{date}_{slug}_{short_id}.md"
def _render(note: NoteItem) -> str:
lines = ["---"]
lines.append(f"id: {note.id}")
if note.title:
lines.append(f'title: "{note.title}"')
if note.category:
lines.append(f"category: {note.category}")
if note.tags:
tags_str = ", ".join(f'"{t}"' for t in note.tags)
lines.append(f"tags: [{tags_str}]")
lines.append(f"created_at: {note.created_at.isoformat()}")
if note.gps_lat is not None and note.gps_lon is not None:
lines.append(f'gps: "{note.gps_lat}, {note.gps_lon}"')
lines.append("---")
lines.append("")
lines.append(note.content)
images = [a for a in note.attachments if a.file_type == "image" and a.file_path]
audios = [a for a in note.attachments if a.file_type == "audio" and a.file_path]
if images or audios:
lines.append("")
lines.append("## Pièces jointes")
lines.append("")
for att in images:
# file_path est relatif à UPLOAD_DIR, ex: "images/originals/uuid.webp"
rel = f"../uploads/{att.file_path}"
name = att.original_name or "image"
lines.append(f"![{name}]({rel})")
for att in audios:
rel = f"../uploads/{att.file_path}"
name = att.original_name or "audio"
lines.append(f"[{name}]({rel})")
return "\n".join(lines) + "\n"
def save_note_markdown(note: NoteItem) -> None:
notes_dir = settings.notes_md_path
notes_dir.mkdir(parents=True, exist_ok=True)
short_id = str(note.id)[:8]
# Supprime l'ancienne version (titre peut avoir changé)
for old in notes_dir.glob(f"*_{short_id}.md"):
old.unlink()
(notes_dir / _note_filename(note)).write_text(_render(note), encoding="utf-8")
def delete_note_markdown(note_id: str) -> None:
notes_dir = settings.notes_md_path
if not notes_dir.exists():
return
short_id = note_id[:8]
for f in notes_dir.glob(f"*_{short_id}.md"):
f.unlink()
View File
+30
View File
@@ -0,0 +1,30 @@
from arq.connections import RedisSettings
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.models.notes import NoteItem
from app.services.notes_markdown import delete_note_markdown, save_note_markdown
async def export_note_markdown(ctx: dict, note_id: str) -> None:
async with AsyncSessionLocal() as session:
stmt = (
select(NoteItem)
.where(NoteItem.id == note_id)
.options(selectinload(NoteItem.attachments))
)
result = await session.execute(stmt)
note = result.scalar_one_or_none()
if note:
save_note_markdown(note)
async def remove_note_markdown(ctx: dict, note_id: str) -> None:
delete_note_markdown(note_id)
class WorkerSettings:
functions = [export_note_markdown, remove_note_markdown]
redis_settings = RedisSettings.from_dsn(settings.redis_url)
+1
View File
@@ -7,5 +7,6 @@ pydantic-settings==2.6.1
pillow==11.1.0 pillow==11.1.0
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.0 httpx==0.28.0
arq==0.26.1
pytest==8.3.4 pytest==8.3.4
pytest-asyncio==0.24.0 pytest-asyncio==0.24.0
View File
View File
+34 -3
View File
@@ -13,20 +13,51 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
backend: backend:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR: /uploads UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
CORS_ORIGINS: http://localhost:3001,http://localhost:3000 CORS_ORIGINS: http://localhost:3001,http://localhost:3000
volumes: volumes:
- ./backend/app:/app/app - ./backend/app:/app/app
- uploads:/uploads - ./data:/data
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
backend-worker:
build: ./backend
command: arq app.workers.notes_worker.WorkerSettings
environment:
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
volumes:
- ./backend/app:/app/app
- ./data:/data
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
frontend: frontend:
build: ./frontend build: ./frontend
@@ -37,4 +68,4 @@ services:
volumes: volumes:
db_data: db_data:
uploads: redis_data:
+34 -5
View File
@@ -177,7 +177,36 @@
--- ---
## Phase 5 — Scan produits + enrichissement catalogue ## Phase 5 — Export Markdown notes + Backup BDD ✅
**Objectif** : persistance double des notes (BDD source de vérité + fichiers Markdown partagés), infrastructure Redis, backup/restore depuis l'interface.
### Architecture
- Volume Docker `./data/` (bind mount) remplace le volume nommé `uploads`
- `data/notes/` — fichiers `.md` auto-générés
- `data/uploads/` — médias (photos, audio)
- `data/backup/` — dumps PostgreSQL
### Backend
- [x] Service Redis : `redis:7-alpine` dans docker-compose
- [x] Worker ARQ (`backend-worker`) : consomme la queue `notes:markdown`, même image que backend
- [x] `app/core/redis.py` — pool ARQ, `enqueue()` best-effort (silence si Redis down)
- [x] `app/workers/notes_worker.py` — tâches `export_note_markdown` + `remove_note_markdown`
- [x] `app/services/notes_markdown.py` — génère le frontmatter YAML + contenu + pièces jointes (liens relatifs `../uploads/…`)
- Nom de fichier : `YYYY-MM-DD_slug-du-titre_shortid.md`
- Rotation automatique si titre modifié (recherche par `*_{shortid}.md`)
- [x] `app/api/notes.py` — publie sur Redis après create / update / delete / add_attachment / delete_attachment
- [x] `app/api/admin.py``POST /api/admin/backup`, `GET /api/admin/backups`, `POST /api/admin/restore/{filename}`
- [x] `backend/Dockerfile` — ajout `postgresql-client` pour pg_dump / pg_restore
- [x] `requirements.txt` — ajout `arq==0.26.1`
### Frontend
- [x] `frontend/src/api/admin.ts` — client TypeScript backup/restore
- [x] Page Config — section "Base de données" : bouton sauvegarde + liste des dumps + bouton Restaurer par fichier
---
## Phase 6 — Scan produits + enrichissement catalogue
**Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts. **Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts.
@@ -203,7 +232,7 @@
--- ---
## Phase 6 — Service OCR (conteneur dédié) ## Phase 7 — Service OCR (conteneur dédié)
**Objectif** : OCR partagé, prérequis pour shopping avancé et notes avancées. **Objectif** : OCR partagé, prérequis pour shopping avancé et notes avancées.
@@ -214,7 +243,7 @@
--- ---
## Phase 7 — Shopping avancé (OCR + suivi prix) ## Phase 8 — Shopping avancé (OCR + suivi prix)
### Backend ### Backend
- [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix - [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix
@@ -228,7 +257,7 @@
--- ---
## Phase 8 — MCP Server ## Phase 9 — MCP Server
**Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.). **Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.).
@@ -262,5 +291,5 @@
## Ordre de développement ## Ordre de développement
``` ```
Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 (Scan) → Phase 6 (OCR) → Phase 7 (Shopping avancé) → Phase 8 (MCP) Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 ✅ → Phase 6 (Scan) → Phase 7 (OCR) → Phase 8 (Shopping avancé) → Phase 9 (MCP)
``` ```
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "homehub-frontend", "name": "homehub-frontend",
"private": true, "private": true,
"version": "0.5.1", "version": "0.5.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+28
View File
@@ -0,0 +1,28 @@
export interface BackupFile {
filename: string
size: number
created_at: string
}
export async function fetchBackups(): Promise<BackupFile[]> {
const res = await fetch('/api/admin/backups')
if (!res.ok) throw new Error('Erreur chargement sauvegardes')
return res.json() as Promise<BackupFile[]>
}
export async function createBackup(): Promise<BackupFile> {
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<BackupFile>
}
export async function restoreBackup(filename: string): Promise<void> {
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')
}
}
+116
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTheme, type ThemeMode } from '../contexts/ThemeContext' import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
import { fetchBackups, createBackup, restoreBackup, type BackupFile } from '../api/admin'
const sectionStyle: React.CSSProperties = { const sectionStyle: React.CSSProperties = {
background: 'var(--bg-3)', background: 'var(--bg-3)',
@@ -31,9 +33,53 @@ const FONT_LABELS: Record<string, string> = {
'1.25': 'XL', '1.3': 'XL+', '1.35': 'XXL', '1.4': 'XXL+', '1.25': 'XL', '1.3': 'XL+', '1.35': 'XXL', '1.4': 'XXL+',
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`
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() { export default function ConfigPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme, fontScale, setFontScale } = useTheme() const { theme, setTheme, fontScale, setFontScale } = useTheme()
const [backups, setBackups] = useState<BackupFile[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [backupError, setBackupError] = useState<string | null>(null)
useEffect(() => {
fetchBackups().then(setBackups).catch(() => setBackups([]))
}, [])
async function handleCreateBackup() {
setBackupLoading(true)
setBackupError(null)
try {
const b = await createBackup()
setBackups(prev => [b, ...prev])
} catch (e) {
setBackupError((e as Error).message)
} finally {
setBackupLoading(false)
}
}
async function handleRestore(filename: string) {
if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return
setRestoring(filename)
setBackupError(null)
try {
await restoreBackup(filename)
} catch (e) {
setBackupError((e as Error).message)
} finally {
setRestoring(null)
}
}
const scaleLabel = FONT_LABELS[String(fontScale)] ?? `${Math.round(fontScale * 100)}%` const scaleLabel = FONT_LABELS[String(fontScale)] ?? `${Math.round(fontScale * 100)}%`
@@ -86,6 +132,76 @@ export default function ConfigPage() {
</div> </div>
</div> </div>
{/* Sauvegarde & Restauration */}
<div style={sectionStyle}>
<div style={labelStyle}>Base de données</div>
<button
onClick={handleCreateBackup}
disabled={backupLoading}
style={{
padding: '10px 16px', borderRadius: 8, border: 'none',
background: 'var(--ok)', color: '#1d2021',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
cursor: backupLoading ? 'default' : 'pointer', minHeight: 44,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: backupLoading ? 0.6 : 1,
}}
>
<i className="fa-solid fa-database" />
{backupLoading ? 'Sauvegarde en cours' : 'Créer une sauvegarde'}
</button>
{backupError && (
<div style={{ color: 'var(--err)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupError}
</div>
)}
{backups.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
<div style={{ ...labelStyle, marginBottom: 0 }}>Sauvegardes disponibles</div>
{backups.map(b => (
<div
key={b.filename}
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px',
}}
>
<i className="fa-solid fa-file-zipper" style={{ color: 'var(--ink-3)', fontSize: 14, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.filename}
</div>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-4)' }}>
{formatDate(b.created_at)} — {formatSize(b.size)}
</div>
</div>
<button
onClick={() => void handleRestore(b.filename)}
disabled={restoring === b.filename}
style={{
padding: '5px 10px', borderRadius: 6,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)', fontFamily: 'var(--font-ui)', fontSize: 11,
cursor: restoring === b.filename ? 'default' : 'pointer',
flexShrink: 0, opacity: restoring === b.filename ? 0.5 : 1,
}}
>
{restoring === b.filename ? '' : 'Restaurer'}
</button>
</div>
))}
</div>
)}
{backups.length === 0 && !backupLoading && (
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
Aucune sauvegarde disponible
</div>
)}
</div>
{/* Taille du texte */} {/* Taille du texte */}
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={labelStyle}>Taille du texte</div> <div style={labelStyle}>Taille du texte</div>