feat(notes): ajout de liens nommés (label + url) sur les notes

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>
This commit is contained in:
2026-05-30 09:47:49 +02:00
co-authored by Claude Sonnet 4.6
parent b084905226
commit 031708ad8f
7 changed files with 185 additions and 4 deletions
+7
View File
@@ -1,3 +1,8 @@
export interface NoteUrl {
label: string
url: string
}
export interface NoteAttachment {
id: string
file_path: string | null
@@ -15,6 +20,7 @@ export interface Note {
tags: string[]
gps_lat: number | null
gps_lon: number | null
urls: NoteUrl[]
created_at: string
attachments: NoteAttachment[]
}
@@ -26,6 +32,7 @@ export interface NoteCreate {
tags?: string[]
gps_lat?: number
gps_lon?: number
urls?: NoteUrl[]
}
export interface NoteFilters {
+97 -2
View File
@@ -1,5 +1,5 @@
import { useState, useRef } from 'react'
import type { Note, NoteCreate } from '../../api/notes'
import type { Note, NoteCreate, NoteUrl } from '../../api/notes'
interface NoteFormProps {
initialValues?: Note
@@ -22,11 +22,24 @@ const inputStyle: React.CSSProperties = {
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)
@@ -40,6 +53,24 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
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) {
@@ -84,6 +115,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
tags: parseTags(tagInput),
gps_lat: gpsLat,
gps_lon: gpsLon,
urls: urls.length > 0 ? urls : [],
})
} catch {
setError('Erreur lors de la sauvegarde')
@@ -131,6 +163,69 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
/>
</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' }}>
@@ -147,7 +242,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
fontFamily: 'var(--font-ui)', fontSize: 13, minHeight: 36,
}}
>
<i className={`fa-solid fa-location-dot`} style={{ marginRight: 6 }} />
<i className="fa-solid fa-location-dot" style={{ marginRight: 6 }} />
{gpsLoading ? '…' : gpsLat != null ? 'GPS capturé' : 'Ajouter GPS'}
</button>
{gpsLat != null && (
+36
View File
@@ -256,12 +256,47 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
{note.urls.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--info)', fontSize: 11 }} title={`${note.urls.length} lien(s)`}>
<i className="fa-solid fa-link" style={{ fontSize: 10 }} />
{note.urls.length}
</span>
)}
{note.gps_lat != null && (
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
)}
</div>
)
const urlsSection = note.urls.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{note.urls.map((u, i) => (
<a
key={i}
href={u.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-4)', borderRadius: 8, padding: '6px 10px',
textDecoration: 'none', overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<i className="fa-solid fa-arrow-up-right-from-square" style={{ color: 'var(--info)', fontSize: 11, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', 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>
</a>
))}
</div>
) : null
const mediaSection = (
<>
{images.length > 0 && (
@@ -383,6 +418,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
{toggleBtn}
</div>
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
{urlsSection}
{mediaSection}
{metaLine}
{actionButtons}