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:
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user