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:
+95
-48
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+40
-20
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user