feat(todos): favicon maison + mode édition double-tap/double-clic

- Favicon SVG maison Gruvbox orange sur fond sombre
- TodoForm accepte initialValues et submitLabel pour l'édition
- SwipeableRow détecte le double-tap (< 300ms, sans déplacement)
- TodosPage : double-tap mobile / double-clic laptop ouvre l'édition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 14:54:47 +02:00
parent 6ef64dfe1c
commit 134678a6f1
5 changed files with 83 additions and 20 deletions
+1
View File
@@ -9,6 +9,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>HomeHub</title>
</head>
<body>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#282828"/>
<path d="M16 5L4 15.5h3V27h7.5v-7h3v7H25V15.5h3L16 5z" fill="#fe8019"/>
<rect x="13.5" y="19" width="5" height="8" fill="#282828"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+20 -8
View File
@@ -2,16 +2,19 @@ import { useRef, useState } from 'react'
interface SwipeableRowProps {
children: React.ReactNode
rightContent: React.ReactNode // actions révélées par swipe gauche
onSwipeRight?: () => void // callback swipe droit (marquer done)
rightContent: React.ReactNode
onSwipeRight?: () => void
onDoubleTap?: () => void
}
const THRESHOLD = 80 // pixels pour déclencher une action
const THRESHOLD = 80
const DOUBLE_TAP_DELAY = 300
export default function SwipeableRow({ children, rightContent, onSwipeRight }: SwipeableRowProps) {
export default function SwipeableRow({ children, rightContent, onSwipeRight, onDoubleTap }: SwipeableRowProps) {
const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const startX = useRef<number | null>(null)
const lastTap = useRef<number>(0)
function onTouchStart(e: React.TouchEvent) {
startX.current = e.touches[0].clientX
@@ -21,14 +24,25 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
function onTouchMove(e: React.TouchEvent) {
if (startX.current === null) return
const dx = e.touches[0].clientX - startX.current
// Clamp : +120px à droite, -160px à gauche (largeur des boutons)
setOffsetX(Math.max(Math.min(dx, 120), -160))
}
function onTouchEnd() {
if (offsetX > THRESHOLD && onSwipeRight) {
const finalOffset = offsetX
if (finalOffset > THRESHOLD && onSwipeRight) {
onSwipeRight()
}
// Double-tap : seulement si le doigt n'a pas bougé (tap, pas swipe)
if (Math.abs(finalOffset) < 10 && onDoubleTap) {
const now = Date.now()
if (now - lastTap.current < DOUBLE_TAP_DELAY) {
onDoubleTap()
}
lastTap.current = now
}
setOffsetX(0)
setIsDragging(false)
startX.current = null
@@ -38,7 +52,6 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
return (
<div style={{ position: 'relative', overflow: 'hidden' }}>
{/* Boutons d'action révélés à droite (swipe gauche) */}
<div
style={{
position: 'absolute',
@@ -56,7 +69,6 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
{rightContent}
</div>
{/* Rangée principale déplaçable */}
<div
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
+14 -11
View File
@@ -9,7 +9,9 @@ const DOMAINS = [
interface TodoFormProps {
onSubmit: (data: TodoCreate) => Promise<void>
onCancel: () => void
extended?: boolean // true = tous les champs (vue laptop)
extended?: boolean
initialValues?: Partial<TodoCreate>
submitLabel?: string
}
const inputStyle: React.CSSProperties = {
@@ -24,14 +26,16 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box',
}
export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoFormProps) {
const [title, setTitle] = useState('')
const [domain, setDomain] = useState('')
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium')
const [dueDate, setDueDate] = useState('')
const [body, setBody] = useState('')
const [url, setUrl] = useState('')
const [tags, setTags] = useState('')
export default function TodoForm({ onSubmit, onCancel, extended = false, initialValues, submitLabel = 'Créer' }: TodoFormProps) {
const [title, setTitle] = useState(initialValues?.title ?? '')
const [domain, setDomain] = useState(initialValues?.domain ?? '')
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(initialValues?.priority ?? 'medium')
const [dueDate, setDueDate] = useState(
initialValues?.due_date ? initialValues.due_date.slice(0, 10) : ''
)
const [body, setBody] = useState(initialValues?.body ?? '')
const [url, setUrl] = useState(initialValues?.url ?? '')
const [tags, setTags] = useState((initialValues?.tags ?? []).join(', '))
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
@@ -81,7 +85,6 @@ export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoF
type="date"
value={dueDate}
onChange={e => setDueDate(e.target.value)}
placeholder="Date objectif"
/>
{extended && (
@@ -139,7 +142,7 @@ export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoF
minHeight: 48,
}}
>
{loading ? '…' : 'Créer'}
{loading ? '…' : submitLabel}
</button>
</div>
</form>
+43 -1
View File
@@ -5,6 +5,8 @@ import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '..
import SwipeableRow from '../components/todos/SwipeableRow'
import TodoForm from '../components/todos/TodoForm'
type EditingTodo = Todo | null
const DOMAINS = [
'informatique', 'diy', 'electronique', 'domotique',
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
@@ -31,6 +33,7 @@ export default function TodosPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingTodo, setEditingTodo] = useState<EditingTodo>(null)
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
const load = useCallback(async () => {
@@ -57,6 +60,16 @@ export default function TodosPage() {
}
}
async function handleUpdate(id: string, data: TodoCreate) {
try {
await updateTodo(id, data)
setEditingTodo(null)
void load()
} catch {
setError('Erreur lors de la mise à jour')
}
}
async function handleDone(id: string) {
try {
await updateTodo(id, { status: 'done' })
@@ -148,6 +161,30 @@ export default function TodosPage() {
</div>
)}
{/* Formulaire d'édition */}
{editingTodo && (
<div className="glass" style={{ padding: 16, borderRadius: 10, marginBottom: 16, borderLeft: '3px solid var(--accent)' }}>
<p style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
Modifier la tâche
</p>
<TodoForm
onSubmit={data => handleUpdate(editingTodo.id, data)}
onCancel={() => setEditingTodo(null)}
extended
submitLabel="Enregistrer"
initialValues={{
title: editingTodo.title,
domain: editingTodo.domain ?? undefined,
priority: editingTodo.priority,
due_date: editingTodo.due_date ?? undefined,
body: editingTodo.body ?? undefined,
url: editingTodo.url ?? undefined,
tags: editingTodo.tags,
}}
/>
</div>
)}
{loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement</p>
)}
@@ -177,6 +214,7 @@ export default function TodosPage() {
<SwipeableRow
key={todo.id}
onSwipeRight={() => void handleDone(todo.id)}
onDoubleTap={() => setEditingTodo(todo)}
rightContent={
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
<button
@@ -265,7 +303,11 @@ export default function TodosPage() {
background: idx % 2 === 0 ? 'transparent' : 'rgba(0,0,0,0.1)',
}}
>
<td style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280 }}>
<td
style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280, cursor: 'pointer' }}
onDoubleClick={() => setEditingTodo(todo)}
title="Double-clic pour modifier"
>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{todo.title}
</div>