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:
@@ -0,0 +1,27 @@
|
|||||||
|
"""006 - ajout colonne urls (JSONB) sur notes.items
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-05-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision = '006'
|
||||||
|
down_revision = '005'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
'items',
|
||||||
|
sa.Column('urls', JSONB, nullable=True),
|
||||||
|
schema='notes',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('items', 'urls', schema='notes')
|
||||||
@@ -18,6 +18,7 @@ class NoteItem(Base):
|
|||||||
tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]"))
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]"))
|
||||||
gps_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
|
gps_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
|
||||||
gps_lon: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
|
gps_lon: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
|
||||||
|
urls: Mapped[list[dict] | None] = mapped_column(JSONB, nullable=True)
|
||||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
|
||||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUrl(BaseModel):
|
||||||
|
label: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@field_validator('url')
|
||||||
|
@classmethod
|
||||||
|
def validate_url(cls, v: str) -> str:
|
||||||
|
if not v.startswith(('http://', 'https://')):
|
||||||
|
raise ValueError('URL doit commencer par http:// ou https://')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AttachmentResponse(BaseModel):
|
class AttachmentResponse(BaseModel):
|
||||||
@@ -20,6 +32,7 @@ class NoteCreate(BaseModel):
|
|||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
gps_lat: float | None = None
|
gps_lat: float | None = None
|
||||||
gps_lon: float | None = None
|
gps_lon: float | None = None
|
||||||
|
urls: list[NoteUrl] = []
|
||||||
|
|
||||||
|
|
||||||
class NoteUpdate(BaseModel):
|
class NoteUpdate(BaseModel):
|
||||||
@@ -29,6 +42,7 @@ class NoteUpdate(BaseModel):
|
|||||||
tags: list[str] | None = None
|
tags: list[str] | None = None
|
||||||
gps_lat: float | None = None
|
gps_lat: float | None = None
|
||||||
gps_lon: float | None = None
|
gps_lon: float | None = None
|
||||||
|
urls: list[NoteUrl] | None = None
|
||||||
|
|
||||||
|
|
||||||
class NoteResponse(BaseModel):
|
class NoteResponse(BaseModel):
|
||||||
@@ -40,5 +54,6 @@ class NoteResponse(BaseModel):
|
|||||||
tags: list[str]
|
tags: list[str]
|
||||||
gps_lat: float | None
|
gps_lat: float | None
|
||||||
gps_lon: float | None
|
gps_lon: float | None
|
||||||
|
urls: list[NoteUrl] = []
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
attachments: list[AttachmentResponse]
|
attachments: list[AttachmentResponse]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.12",
|
"version": "0.5.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface NoteUrl {
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface NoteAttachment {
|
export interface NoteAttachment {
|
||||||
id: string
|
id: string
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
@@ -15,6 +20,7 @@ export interface Note {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
gps_lat: number | null
|
gps_lat: number | null
|
||||||
gps_lon: number | null
|
gps_lon: number | null
|
||||||
|
urls: NoteUrl[]
|
||||||
created_at: string
|
created_at: string
|
||||||
attachments: NoteAttachment[]
|
attachments: NoteAttachment[]
|
||||||
}
|
}
|
||||||
@@ -26,6 +32,7 @@ export interface NoteCreate {
|
|||||||
tags?: string[]
|
tags?: string[]
|
||||||
gps_lat?: number
|
gps_lat?: number
|
||||||
gps_lon?: number
|
gps_lon?: number
|
||||||
|
urls?: NoteUrl[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteFilters {
|
export interface NoteFilters {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import type { Note, NoteCreate } from '../../api/notes'
|
import type { Note, NoteCreate, NoteUrl } from '../../api/notes'
|
||||||
|
|
||||||
interface NoteFormProps {
|
interface NoteFormProps {
|
||||||
initialValues?: Note
|
initialValues?: Note
|
||||||
@@ -22,11 +22,24 @@ const inputStyle: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
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) {
|
export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabel = 'Créer' }: NoteFormProps) {
|
||||||
const [title, setTitle] = useState(initialValues?.title ?? '')
|
const [title, setTitle] = useState(initialValues?.title ?? '')
|
||||||
const [content, setContent] = useState(initialValues?.content ?? '')
|
const [content, setContent] = useState(initialValues?.content ?? '')
|
||||||
const [category, setCategory] = useState(initialValues?.category ?? '')
|
const [category, setCategory] = useState(initialValues?.category ?? '')
|
||||||
const [tagInput, setTagInput] = useState(initialValues?.tags.join(', ') ?? '')
|
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 [gpsLat, setGpsLat] = useState<number | undefined>(initialValues?.gps_lat ?? undefined)
|
||||||
const [gpsLon, setGpsLon] = useState<number | undefined>(initialValues?.gps_lon ?? undefined)
|
const [gpsLon, setGpsLon] = useState<number | undefined>(initialValues?.gps_lon ?? undefined)
|
||||||
const [gpsLoading, setGpsLoading] = useState(false)
|
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)
|
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() {
|
function handleGps() {
|
||||||
setGpsError(null)
|
setGpsError(null)
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
@@ -84,6 +115,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
|
|||||||
tags: parseTags(tagInput),
|
tags: parseTags(tagInput),
|
||||||
gps_lat: gpsLat,
|
gps_lat: gpsLat,
|
||||||
gps_lon: gpsLon,
|
gps_lon: gpsLon,
|
||||||
|
urls: urls.length > 0 ? urls : [],
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setError('Erreur lors de la sauvegarde')
|
setError('Erreur lors de la sauvegarde')
|
||||||
@@ -131,6 +163,69 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* GPS */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<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,
|
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'}
|
{gpsLoading ? '…' : gpsLat != null ? 'GPS capturé' : 'Ajouter GPS'}
|
||||||
</button>
|
</button>
|
||||||
{gpsLat != null && (
|
{gpsLat != null && (
|
||||||
|
|||||||
@@ -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)`} />}
|
{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)`} />}
|
{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)`} />}
|
{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 && (
|
{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)}`} />
|
<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>
|
</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 = (
|
const mediaSection = (
|
||||||
<>
|
<>
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
@@ -383,6 +418,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
|||||||
{toggleBtn}
|
{toggleBtn}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
|
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
|
||||||
|
{urlsSection}
|
||||||
{mediaSection}
|
{mediaSection}
|
||||||
{metaLine}
|
{metaLine}
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
|
|||||||
Reference in New Issue
Block a user