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 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 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="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>
|
<title>HomeHub</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 {
|
interface SwipeableRowProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
rightContent: React.ReactNode // actions révélées par swipe gauche
|
rightContent: React.ReactNode
|
||||||
onSwipeRight?: () => void // callback swipe droit (marquer done)
|
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 [offsetX, setOffsetX] = useState(0)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const startX = useRef<number | null>(null)
|
const startX = useRef<number | null>(null)
|
||||||
|
const lastTap = useRef<number>(0)
|
||||||
|
|
||||||
function onTouchStart(e: React.TouchEvent) {
|
function onTouchStart(e: React.TouchEvent) {
|
||||||
startX.current = e.touches[0].clientX
|
startX.current = e.touches[0].clientX
|
||||||
@@ -21,14 +24,25 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
|
|||||||
function onTouchMove(e: React.TouchEvent) {
|
function onTouchMove(e: React.TouchEvent) {
|
||||||
if (startX.current === null) return
|
if (startX.current === null) return
|
||||||
const dx = e.touches[0].clientX - startX.current
|
const dx = e.touches[0].clientX - startX.current
|
||||||
// Clamp : +120px à droite, -160px à gauche (largeur des boutons)
|
|
||||||
setOffsetX(Math.max(Math.min(dx, 120), -160))
|
setOffsetX(Math.max(Math.min(dx, 120), -160))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() {
|
||||||
if (offsetX > THRESHOLD && onSwipeRight) {
|
const finalOffset = offsetX
|
||||||
|
|
||||||
|
if (finalOffset > THRESHOLD && onSwipeRight) {
|
||||||
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)
|
setOffsetX(0)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
startX.current = null
|
startX.current = null
|
||||||
@@ -38,7 +52,6 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
||||||
{/* Boutons d'action révélés à droite (swipe gauche) */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -56,7 +69,6 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight }: S
|
|||||||
{rightContent}
|
{rightContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rangée principale déplaçable */}
|
|
||||||
<div
|
<div
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const DOMAINS = [
|
|||||||
interface TodoFormProps {
|
interface TodoFormProps {
|
||||||
onSubmit: (data: TodoCreate) => Promise<void>
|
onSubmit: (data: TodoCreate) => Promise<void>
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
extended?: boolean // true = tous les champs (vue laptop)
|
extended?: boolean
|
||||||
|
initialValues?: Partial<TodoCreate>
|
||||||
|
submitLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -24,14 +26,16 @@ const inputStyle: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoFormProps) {
|
export default function TodoForm({ onSubmit, onCancel, extended = false, initialValues, submitLabel = 'Créer' }: TodoFormProps) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState(initialValues?.title ?? '')
|
||||||
const [domain, setDomain] = useState('')
|
const [domain, setDomain] = useState(initialValues?.domain ?? '')
|
||||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium')
|
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(initialValues?.priority ?? 'medium')
|
||||||
const [dueDate, setDueDate] = useState('')
|
const [dueDate, setDueDate] = useState(
|
||||||
const [body, setBody] = useState('')
|
initialValues?.due_date ? initialValues.due_date.slice(0, 10) : ''
|
||||||
const [url, setUrl] = useState('')
|
)
|
||||||
const [tags, setTags] = useState('')
|
const [body, setBody] = useState(initialValues?.body ?? '')
|
||||||
|
const [url, setUrl] = useState(initialValues?.url ?? '')
|
||||||
|
const [tags, setTags] = useState((initialValues?.tags ?? []).join(', '))
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -81,7 +85,6 @@ export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoF
|
|||||||
type="date"
|
type="date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={e => setDueDate(e.target.value)}
|
onChange={e => setDueDate(e.target.value)}
|
||||||
placeholder="Date objectif"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{extended && (
|
{extended && (
|
||||||
@@ -139,7 +142,7 @@ export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoF
|
|||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'Créer'}
|
{loading ? '…' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '..
|
|||||||
import SwipeableRow from '../components/todos/SwipeableRow'
|
import SwipeableRow from '../components/todos/SwipeableRow'
|
||||||
import TodoForm from '../components/todos/TodoForm'
|
import TodoForm from '../components/todos/TodoForm'
|
||||||
|
|
||||||
|
type EditingTodo = Todo | null
|
||||||
|
|
||||||
const DOMAINS = [
|
const DOMAINS = [
|
||||||
'informatique', 'diy', 'electronique', 'domotique',
|
'informatique', 'diy', 'electronique', 'domotique',
|
||||||
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
||||||
@@ -31,6 +33,7 @@ export default function TodosPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingTodo, setEditingTodo] = useState<EditingTodo>(null)
|
||||||
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
|
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
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) {
|
async function handleDone(id: string) {
|
||||||
try {
|
try {
|
||||||
await updateTodo(id, { status: 'done' })
|
await updateTodo(id, { status: 'done' })
|
||||||
@@ -148,6 +161,30 @@ export default function TodosPage() {
|
|||||||
</div>
|
</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 && (
|
{loading && (
|
||||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>
|
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>
|
||||||
)}
|
)}
|
||||||
@@ -177,6 +214,7 @@ export default function TodosPage() {
|
|||||||
<SwipeableRow
|
<SwipeableRow
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
onSwipeRight={() => void handleDone(todo.id)}
|
onSwipeRight={() => void handleDone(todo.id)}
|
||||||
|
onDoubleTap={() => setEditingTodo(todo)}
|
||||||
rightContent={
|
rightContent={
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
|
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
|
||||||
<button
|
<button
|
||||||
@@ -265,7 +303,11 @@ export default function TodosPage() {
|
|||||||
background: idx % 2 === 0 ? 'transparent' : 'rgba(0,0,0,0.1)',
|
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' }}>
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{todo.title}
|
{todo.title}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user