feat(ux): user-select global, swipe-gauche édition, clavier numérique, versionnage v0.4.0
- user-select:none global (index.css) + reset sur input/textarea/select - ItemRow: swipe gauche → édition (fond bleu), suppression long press, bouton ✕ toujours visible sur mobile - SwipeableRow: prop onSwipeLeft, révèle rightContent entre seuil/2 et seuil, déclenche onSwipeLeft au seuil complet - TodosPage: onSwipeLeft → édition (remplace double-tap) - inputMode=decimal sur tous les champs quantité et prix - formatQty: affiche "2" au lieu de "2.000" - Versionnage: __APP_VERSION__ injecté par Vite depuis package.json v0.4.0 - HomePage: version affichée à côté du titre (v0.4.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -228,11 +228,13 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
style={inputStyle} placeholder="Prix (€)"
|
style={inputStyle} placeholder="Prix (€)"
|
||||||
|
inputMode="decimal"
|
||||||
value={form.price ?? ''}
|
value={form.price ?? ''}
|
||||||
onChange={e => setForm(f => ({ ...f, price: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, price: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
style={inputStyle} placeholder="Qté/unité"
|
style={inputStyle} placeholder="Qté/unité"
|
||||||
|
inputMode="decimal"
|
||||||
value={form.quantity_per_unit ?? ''}
|
value={form.quantity_per_unit ?? ''}
|
||||||
onChange={e => setForm(f => ({ ...f, quantity_per_unit: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, quantity_per_unit: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,57 +10,38 @@ interface ItemRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 80
|
const SWIPE_THRESHOLD = 80
|
||||||
const LONG_PRESS_MS = 500
|
|
||||||
|
function formatQty(qty: string): string {
|
||||||
|
const n = parseFloat(qty)
|
||||||
|
if (isNaN(n)) return qty
|
||||||
|
return n % 1 === 0 ? String(Math.round(n)) : n.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) {
|
export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) {
|
||||||
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 longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const didLongPress = useRef(false)
|
|
||||||
|
|
||||||
function clearLongPress() {
|
|
||||||
if (longPressTimer.current) {
|
|
||||||
clearTimeout(longPressTimer.current)
|
|
||||||
longPressTimer.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart(e: React.TouchEvent) {
|
function onTouchStart(e: React.TouchEvent) {
|
||||||
startX.current = e.touches[0].clientX
|
startX.current = e.touches[0].clientX
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
didLongPress.current = false
|
|
||||||
|
|
||||||
if (onEdit) {
|
|
||||||
longPressTimer.current = setTimeout(() => {
|
|
||||||
didLongPress.current = true
|
|
||||||
onEdit()
|
|
||||||
}, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
if (Math.abs(dx) > 8) clearLongPress()
|
|
||||||
if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120))
|
if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() {
|
||||||
clearLongPress()
|
if (!storeMode && offsetX < -SWIPE_THRESHOLD && onEdit) {
|
||||||
if (!storeMode && offsetX < -SWIPE_THRESHOLD) {
|
onEdit()
|
||||||
onDelete()
|
|
||||||
}
|
}
|
||||||
setOffsetX(0)
|
setOffsetX(0)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
startX.current = null
|
startX.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (didLongPress.current) return
|
|
||||||
onCheck()
|
|
||||||
}
|
|
||||||
|
|
||||||
const minHeight = storeMode ? 64 : 52
|
const minHeight = storeMode ? 64 : 52
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,11 +50,11 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
|
|||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', right: 0, top: 0, bottom: 0,
|
position: 'absolute', right: 0, top: 0, bottom: 0,
|
||||||
width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
background: 'var(--err)',
|
background: 'var(--info)',
|
||||||
opacity: offsetX < -20 ? 1 : 0,
|
opacity: offsetX < -20 ? 1 : 0,
|
||||||
transition: 'opacity 0.15s',
|
transition: 'opacity 0.15s',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: '#fff', fontSize: 20 }}>✕</span>
|
<span style={{ color: '#fff', fontSize: 20 }}>✏️</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -81,7 +62,7 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
|
|||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onClick={handleClick}
|
onClick={onCheck}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offsetX}px)`,
|
transform: `translateX(${offsetX}px)`,
|
||||||
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
||||||
@@ -126,7 +107,7 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
|
|||||||
</div>
|
</div>
|
||||||
{(item.quantity || item.unit) && (
|
{(item.quantity || item.unit) && (
|
||||||
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}>
|
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}>
|
||||||
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
|
{item.quantity ? formatQty(item.quantity) : ''}{item.unit ? ` ${item.unit}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,17 +134,15 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
|
|||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suppression mode magasin mobile uniquement */}
|
{/* Suppression mobile — visible sur toutes les lignes */}
|
||||||
{storeMode && !item.is_checked && (
|
<button
|
||||||
<button
|
className="flex lg:hidden"
|
||||||
className="flex lg:hidden"
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
style={{
|
||||||
style={{
|
background: 'transparent', border: 'none', color: 'var(--ink-4)',
|
||||||
background: 'transparent', border: 'none', color: 'var(--ink-4)',
|
fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
|
||||||
fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
|
}}
|
||||||
}}
|
>✕</button>
|
||||||
>✕</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ interface SwipeableRowProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
rightContent: React.ReactNode
|
rightContent: React.ReactNode
|
||||||
onSwipeRight?: () => void
|
onSwipeRight?: () => void
|
||||||
onDoubleTap?: () => void
|
onSwipeLeft?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const THRESHOLD = 80
|
const THRESHOLD = 80
|
||||||
const DOUBLE_TAP_DELAY = 300
|
|
||||||
|
|
||||||
export default function SwipeableRow({ children, rightContent, onSwipeRight, onDoubleTap }: SwipeableRowProps) {
|
export default function SwipeableRow({ children, rightContent, onSwipeRight, onSwipeLeft }: 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
|
||||||
@@ -28,27 +26,14 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight, onD
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() {
|
||||||
const finalOffset = offsetX
|
if (offsetX > THRESHOLD && onSwipeRight) onSwipeRight()
|
||||||
|
if (offsetX < -THRESHOLD && onSwipeLeft) onSwipeLeft()
|
||||||
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)
|
setOffsetX(0)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
startX.current = null
|
startX.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const revealActions = offsetX < -(THRESHOLD / 2)
|
const revealActions = offsetX < -(THRESHOLD / 2) && offsetX > -THRESHOLD
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
||||||
@@ -76,7 +61,11 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight, onD
|
|||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offsetX}px)`,
|
transform: `translateX(${offsetX}px)`,
|
||||||
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
||||||
background: offsetX > THRESHOLD / 2 ? 'var(--ok)' : 'var(--bg-3)',
|
background: offsetX > THRESHOLD / 2
|
||||||
|
? 'var(--ok)'
|
||||||
|
: offsetX < -THRESHOLD
|
||||||
|
? 'var(--info)'
|
||||||
|
: 'var(--bg-3)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}>
|
<div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}>
|
||||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||||
HomeHub
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}>
|
||||||
</h1>
|
HomeHub
|
||||||
|
</h1>
|
||||||
|
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
v{__APP_VERSION__}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p style={{ color: 'var(--ink-2)', marginTop: 8 }}>
|
<p style={{ color: 'var(--ink-2)', marginTop: 8 }}>
|
||||||
Application d'organisation personnelle
|
Application d'organisation personnelle
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ export default function ShoppingPage() {
|
|||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
placeholder="Quantité"
|
placeholder="Quantité"
|
||||||
|
inputMode="decimal"
|
||||||
value={editQty}
|
value={editQty}
|
||||||
onChange={e => setEditQty(e.target.value)}
|
onChange={e => setEditQty(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -555,6 +556,7 @@ export default function ShoppingPage() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
style={inputStyle} placeholder="Quantité"
|
style={inputStyle} placeholder="Quantité"
|
||||||
|
inputMode="decimal"
|
||||||
value={newItemQty}
|
value={newItemQty}
|
||||||
onChange={e => setNewItemQty(e.target.value)}
|
onChange={e => setNewItemQty(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -197,7 +197,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)}
|
onSwipeLeft={() => setEditingTodo(todo)}
|
||||||
rightContent={
|
rightContent={
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
|
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user