031708ad8f
Backend : - Migration 006 : colonne urls JSONB nullable sur notes.items - Modèle NoteItem : champ urls list[dict] - Schémas : NoteUrl (label + url avec validation http/https), NoteCreate/NoteUpdate/NoteResponse exposent urls Frontend : - api/notes.ts : interface NoteUrl + champ urls sur Note/NoteCreate - NoteForm : section "Liens" avec ajout (libellé + URL), suppression, validation http/https, confirmation par Enter - NotesPage : badge compteur liens dans metaLine (semi/collapsed), section liens cliquables dans le mode expanded v0.5.13 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import type { Note, NoteCreate, NoteUrl } from '../../api/notes'
|
|
|
|
interface NoteFormProps {
|
|
initialValues?: Note
|
|
onSubmit: (data: NoteCreate) => Promise<void>
|
|
onCancel: () => void
|
|
submitLabel?: string
|
|
}
|
|
|
|
const CATEGORIES = ['personnel', 'travail', 'idée', 'recette', 'voyage', 'santé', 'autre']
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
background: 'var(--bg-4)',
|
|
border: '1px solid var(--bg-5)',
|
|
borderRadius: 8,
|
|
padding: '8px 12px',
|
|
color: 'var(--ink-1)',
|
|
fontFamily: 'var(--font-ui)',
|
|
fontSize: 14,
|
|
boxSizing: 'border-box',
|
|
}
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
color: 'var(--ink-3)',
|
|
fontSize: 11,
|
|
fontFamily: 'var(--font-ui)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
marginBottom: 6,
|
|
}
|
|
|
|
export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabel = 'Créer' }: NoteFormProps) {
|
|
const [title, setTitle] = useState(initialValues?.title ?? '')
|
|
const [content, setContent] = useState(initialValues?.content ?? '')
|
|
const [category, setCategory] = useState(initialValues?.category ?? '')
|
|
const [tagInput, setTagInput] = useState(initialValues?.tags.join(', ') ?? '')
|
|
const [urls, setUrls] = useState<NoteUrl[]>(initialValues?.urls ?? [])
|
|
const [urlLabel, setUrlLabel] = useState('')
|
|
const [urlHref, setUrlHref] = useState('')
|
|
const [urlError, setUrlError] = useState<string | null>(null)
|
|
const [gpsLat, setGpsLat] = useState<number | undefined>(initialValues?.gps_lat ?? undefined)
|
|
const [gpsLon, setGpsLon] = useState<number | undefined>(initialValues?.gps_lon ?? undefined)
|
|
const [gpsLoading, setGpsLoading] = useState(false)
|
|
const [gpsError, setGpsError] = useState<string | null>(null)
|
|
const [gpsManual, setGpsManual] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const contentRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
function parseTags(raw: string): string[] {
|
|
return raw.split(',').map(t => t.trim()).filter(Boolean)
|
|
}
|
|
|
|
function addUrl() {
|
|
const href = urlHref.trim()
|
|
const label = urlLabel.trim() || href
|
|
if (!href) return
|
|
if (!href.startsWith('http://') && !href.startsWith('https://')) {
|
|
setUrlError('URL doit commencer par http:// ou https://')
|
|
return
|
|
}
|
|
setUrls(prev => [...prev, { label, url: href }])
|
|
setUrlLabel('')
|
|
setUrlHref('')
|
|
setUrlError(null)
|
|
}
|
|
|
|
function removeUrl(idx: number) {
|
|
setUrls(prev => prev.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
function handleGps() {
|
|
setGpsError(null)
|
|
if (!navigator.geolocation) {
|
|
setGpsError('GPS non disponible (HTTPS requis hors localhost)')
|
|
setGpsManual(true)
|
|
return
|
|
}
|
|
setGpsLoading(true)
|
|
navigator.geolocation.getCurrentPosition(
|
|
pos => {
|
|
setGpsLat(pos.coords.latitude)
|
|
setGpsLon(pos.coords.longitude)
|
|
setGpsLoading(false)
|
|
setGpsManual(false)
|
|
},
|
|
err => {
|
|
setGpsLoading(false)
|
|
if (err.code === 1) setGpsError('Permission refusée')
|
|
else if (err.code === 2) setGpsError('Position indisponible — saisie manuelle ?')
|
|
else setGpsError('Délai dépassé — saisie manuelle ?')
|
|
setGpsManual(true)
|
|
},
|
|
{ timeout: 8000 },
|
|
)
|
|
}
|
|
|
|
function handleManualCoord(field: 'lat' | 'lon', val: string) {
|
|
const n = parseFloat(val.replace(',', '.'))
|
|
if (field === 'lat') setGpsLat(isNaN(n) ? undefined : n)
|
|
else setGpsLon(isNaN(n) ? undefined : n)
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (!content.trim()) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
await onSubmit({
|
|
title: title.trim() || undefined,
|
|
content: content.trim(),
|
|
category: category || undefined,
|
|
tags: parseTags(tagInput),
|
|
gps_lat: gpsLat,
|
|
gps_lon: gpsLon,
|
|
urls: urls.length > 0 ? urls : [],
|
|
})
|
|
} catch {
|
|
setError('Erreur lors de la sauvegarde')
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{error && (
|
|
<p style={{ color: 'var(--err)', fontSize: 13, fontFamily: 'var(--font-ui)', margin: 0 }}>{error}</p>
|
|
)}
|
|
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Titre (optionnel)"
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
/>
|
|
|
|
<textarea
|
|
ref={contentRef}
|
|
style={{ ...inputStyle, minHeight: 120, resize: 'vertical' }}
|
|
placeholder="Contenu *"
|
|
value={content}
|
|
onChange={e => setContent(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
<select
|
|
style={inputStyle}
|
|
value={category}
|
|
onChange={e => setCategory(e.target.value)}
|
|
>
|
|
<option value="">Catégorie</option>
|
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Tags (virgule)"
|
|
value={tagInput}
|
|
onChange={e => setTagInput(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* URLs */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={labelStyle}>Liens</div>
|
|
|
|
{urls.map((u, idx) => (
|
|
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-4)', borderRadius: 8, padding: '6px 10px' }}>
|
|
<i className="fa-solid fa-link" style={{ color: 'var(--ink-4)', fontSize: 12, flexShrink: 0 }} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{u.label}
|
|
</div>
|
|
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{u.url}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeUrl(idx)}
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0, padding: '2px 4px' }}
|
|
>✕</button>
|
|
</div>
|
|
))}
|
|
|
|
{/* Formulaire ajout */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<input
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
placeholder="Libellé (ex: Tuto vidéo)"
|
|
value={urlLabel}
|
|
onChange={e => setUrlLabel(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<input
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
placeholder="https://…"
|
|
value={urlHref}
|
|
onChange={e => { setUrlHref(e.target.value); setUrlError(null) }}
|
|
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())}
|
|
type="url"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={addUrl}
|
|
disabled={!urlHref.trim()}
|
|
style={{
|
|
padding: '6px 14px', borderRadius: 8, border: 'none',
|
|
background: urlHref.trim() ? 'var(--accent)' : 'var(--bg-5)',
|
|
color: urlHref.trim() ? '#1d2021' : 'var(--ink-4)',
|
|
cursor: urlHref.trim() ? 'pointer' : 'default',
|
|
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
|
minHeight: 36, flexShrink: 0,
|
|
}}
|
|
>+ Ajouter</button>
|
|
</div>
|
|
{urlError && (
|
|
<span style={{ color: 'var(--err)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>{urlError}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* GPS */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<button
|
|
type="button"
|
|
onClick={handleGps}
|
|
disabled={gpsLoading}
|
|
style={{
|
|
padding: '6px 14px', borderRadius: 8,
|
|
border: '1px solid var(--bg-5)',
|
|
background: gpsLat != null ? 'var(--info)' : 'var(--bg-4)',
|
|
color: gpsLat != null ? '#fff' : 'var(--ink-2)',
|
|
cursor: gpsLoading ? 'default' : 'pointer',
|
|
fontFamily: 'var(--font-ui)', fontSize: 13, minHeight: 36,
|
|
}}
|
|
>
|
|
<i className="fa-solid fa-location-dot" style={{ marginRight: 6 }} />
|
|
{gpsLoading ? '…' : gpsLat != null ? 'GPS capturé' : 'Ajouter GPS'}
|
|
</button>
|
|
{gpsLat != null && (
|
|
<>
|
|
<span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)', flex: 1 }}>
|
|
{gpsLat.toFixed(5)}, {gpsLon?.toFixed(5)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setGpsLat(undefined); setGpsLon(undefined); setGpsManual(false); setGpsError(null) }}
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
|
|
>✕</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{gpsError && (
|
|
<span style={{ color: 'var(--warn)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>
|
|
{gpsError}
|
|
</span>
|
|
)}
|
|
|
|
{gpsManual && gpsLat == null && (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<input
|
|
style={{ ...inputStyle, flex: 1, fontSize: 12 }}
|
|
placeholder="Latitude (ex: 48.8566)"
|
|
onChange={e => handleManualCoord('lat', e.target.value)}
|
|
/>
|
|
<input
|
|
style={{ ...inputStyle, flex: 1, fontSize: 12 }}
|
|
placeholder="Longitude (ex: 2.3522)"
|
|
onChange={e => handleManualCoord('lon', e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
|
<button
|
|
onClick={onCancel}
|
|
style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 40 }}
|
|
>Annuler</button>
|
|
<button
|
|
onClick={() => void handleSubmit()}
|
|
disabled={saving || !content.trim()}
|
|
style={{ padding: '8px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 40, opacity: saving ? 0.7 : 1 }}
|
|
>{saving ? '…' : submitLabel}</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|