fix(ui): bouton + laptop, overflow mobile, clôture semaine, backup complet

- TodosPage/ShoppingPage : bouton « + » visible en laptop (hidden lg:flex)
- ShoppingPage : renomme « Terminer » en « Clôturer la semaine », badge ⚠
  si semaine ISO dépassée, confirmation modale avec décompte non-cochés
- NotesPage : overflowWrap:anywhere sur titre/contenu/markdown (URLs longues
  qui débordaient hors de la tuile sur smartphone)
- index.css : overflow-x:hidden + max-width:100vw sur html/body (garde-fou global)
- admin.py : backup remplacé par archive .tar.gz (pg_dump + uploads/) streamée
  au navigateur ; restore via multipart upload avec extraction sécurisée
- admin.ts : downloadBackup() (blob trigger) + uploadAndRestore() avec progression XHR
- ConfigPage : refonte section backup avec boutons Télécharger/Restaurer
  et barre de progression upload

v0.5.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 08:52:37 +02:00
parent 69c2042995
commit 4c616fa5d3
8 changed files with 316 additions and 142 deletions
+95 -48
View File
@@ -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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"scripts": {
"dev": "vite",
+40 -20
View File
@@ -18,31 +18,51 @@ export async function fetchStats(): Promise<AppStats> {
return res.json() as Promise<AppStats>
}
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> {
export async function downloadBackup(): Promise<string> {
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>
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<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')
}
export async function uploadAndRestore(file: File, onProgress?: (pct: number) => void): Promise<void> {
await new Promise<void>((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)
})
}
+6 -1
View File
@@ -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;
}
+69 -60
View File
@@ -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<AppStats | null>(null)
const [backups, setBackups] = useState<BackupFile[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [restoring, setRestoring] = useState(false)
const [restoreProgress, setRestoreProgress] = useState(0)
const [backupError, setBackupError] = useState<string | null>(null)
const [backupInfo, setBackupInfo] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(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 */}
<div style={sectionStyle}>
<div style={labelStyle}>Base de données</div>
<div style={labelStyle}>Sauvegarde complète (BDD + médias)</div>
<button
onClick={handleCreateBackup}
disabled={backupLoading}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8, border: 'none',
background: 'var(--ok)', color: '#1d2021',
@@ -202,59 +207,63 @@ export default function ConfigPage() {
opacity: backupLoading ? 0.6 : 1,
}}
>
<i className="fa-solid fa-database" />
{backupLoading ? 'Sauvegarde en cours' : 'Créer une sauvegarde'}
<i className="fa-solid fa-download" />
{backupLoading ? 'Préparation' : 'Télécharger une archive'}
</button>
<input
ref={fileRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) void handleRestoreFile(f)
}}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
cursor: restoring ? 'default' : 'pointer', minHeight: 44,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: restoring ? 0.6 : 1,
}}
>
<i className="fa-solid fa-upload" />
{restoring
? (restoreProgress < 100 ? `Upload ${restoreProgress}%…` : 'Restauration en cours')
: 'Restaurer depuis une archive'}
</button>
{restoring && (
<div style={{ height: 6, background: 'var(--bg-4)', borderRadius: 999, overflow: 'hidden' }}>
<div style={{
width: `${restoreProgress}%`, height: '100%',
background: 'var(--warn)', transition: 'width 0.2s ease',
}} />
</div>
)}
{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>
))}
{backupInfo && (
<div style={{ color: 'var(--ok)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupInfo}
</div>
)}
{backups.length === 0 && !backupLoading && (
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
Aucune sauvegarde disponible
</div>
)}
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 11, lineHeight: 1.4 }}>
L'archive contient le dump PostgreSQL et tout le dossier <code style={{ fontFamily: 'var(--font-mono)' }}>uploads/</code> (photos, audio, vidéos). À restaurer sur une instance compatible.
</div>
</div>
{/* Taille du texte */}
+10 -9
View File
@@ -326,10 +326,10 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── COLLAPSED ───────────────────────────────────────────────────────────────
if (state === 'collapsed') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px' }}>
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400 }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400, overflowWrap: 'anywhere' }}>
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')}
</span>
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
@@ -345,17 +345,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── SEMI (défaut) ───────────────────────────────────────────────────────────
if (state === 'semi') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
<div style={{
color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
overflowWrap: 'anywhere',
} as React.CSSProperties}>
{note.content}
</div>
@@ -370,18 +371,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── EXPANDED ────────────────────────────────────────────────────────────────
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0 }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
</div>
{toggleBtn}
</div>
<div>{renderMarkdown(note.content)}</div>
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
{mediaSection}
{metaLine}
{actionButtons}
+88 -3
View File
@@ -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</button>
{hasCurrentList && (
<button
className="hidden lg:flex"
onClick={openAddSheet}
style={{
alignItems: 'center', gap: 8,
background: 'var(--accent)', border: 'none',
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
...noSelect,
}}
>
<i className="fa-solid fa-cart-plus" /> Article
</button>
)}
</div>
{/* ── Erreur ── */}
@@ -496,10 +532,27 @@ export default function ShoppingPage() {
padding: '8px 16px',
background: 'var(--bg-3)',
borderBottom: '1px solid var(--bg-4)',
flexWrap: 'wrap',
}}>
<span style={{ flex: 1, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{checkedItems.length}/{currentList.item_count} cochés
</span>
{isListOutdated(currentList.name) && (
<span
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
style={{
background: 'var(--warn)', color: '#1d2021',
borderRadius: 999, padding: '2px 8px',
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4,
...noSelect,
}}
>
<i className="fa-solid fa-triangle-exclamation" style={{ fontSize: 10 }} />
semaine dépassée
</span>
)}
<div style={{ flex: 1 }} />
{pastLists.length > 0 && (
<button
onClick={() => setShowHistoryModal(true)}
@@ -511,14 +564,17 @@ export default function ShoppingPage() {
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer</button>
<button
onClick={() => void handleFinish()}
onClick={() => setShowFinishConfirm(true)}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>Terminer </button>
>
<i className="fa-solid fa-check" /> Clôturer la semaine
</button>
</div>
{/* Articles non cochés */}
@@ -801,6 +857,35 @@ export default function ShoppingPage() {
onStoresChanged={() => void loadData()}
/>
)}
{showFinishConfirm && currentList && (
<Modal title="Clôturer la semaine ?" onClose={() => setShowFinishConfirm(false)} width={420}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p style={{ margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 14, lineHeight: 1.5 }}>
La liste <strong style={{ color: 'var(--ink-1)' }}>{currentList.name ?? 'en cours'}</strong> va être archivée.
</p>
{uncheckedItems.length > 0 ? (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5 }}>
<strong style={{ color: 'var(--warn)' }}>{uncheckedItems.length}</strong> 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.
</p>
) : (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Tous les articles sont cochés. Une nouvelle liste vide sera créée pour la semaine en cours.
</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowFinishConfirm(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleFinish()}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44 }}
>Clôturer</button>
</div>
</div>
</Modal>
)}
</div>
)
}
+7
View File
@@ -149,6 +149,13 @@ export default function TodosPage() {
<option value="cancelled">Annulé</option>
<option value="">Tous</option>
</select>
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{ alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, cursor: 'pointer', ...noSelect }}
>
<i className="fa-solid fa-plus" /> Nouvelle tâche
</button>
</div>
{error && (