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
+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 && (