From 031708ad8f7c6299aa54d5c731f53670beba77f9 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sat, 30 May 2026 09:47:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(notes):=20ajout=20de=20liens=20nomm=C3=A9s?= =?UTF-8?q?=20(label=20+=20url)=20sur=20les=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/alembic/versions/006_notes_urls.py | 27 ++++++ backend/app/models/notes.py | 1 + backend/app/schemas/notes.py | 17 +++- frontend/package.json | 2 +- frontend/src/api/notes.ts | 7 ++ frontend/src/components/notes/NoteForm.tsx | 99 +++++++++++++++++++++- frontend/src/pages/NotesPage.tsx | 36 ++++++++ 7 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/006_notes_urls.py diff --git a/backend/alembic/versions/006_notes_urls.py b/backend/alembic/versions/006_notes_urls.py new file mode 100644 index 0000000..49b2adc --- /dev/null +++ b/backend/alembic/versions/006_notes_urls.py @@ -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') diff --git a/backend/app/models/notes.py b/backend/app/models/notes.py index 4681ba7..6dd5a4c 100644 --- a/backend/app/models/notes.py +++ b/backend/app/models/notes.py @@ -18,6 +18,7 @@ class NoteItem(Base): tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]")) gps_lat: 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) 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)) diff --git a/backend/app/schemas/notes.py b/backend/app/schemas/notes.py index e28daf9..9098006 100644 --- a/backend/app/schemas/notes.py +++ b/backend/app/schemas/notes.py @@ -1,6 +1,18 @@ import uuid 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): @@ -20,6 +32,7 @@ class NoteCreate(BaseModel): tags: list[str] = [] gps_lat: float | None = None gps_lon: float | None = None + urls: list[NoteUrl] = [] class NoteUpdate(BaseModel): @@ -29,6 +42,7 @@ class NoteUpdate(BaseModel): tags: list[str] | None = None gps_lat: float | None = None gps_lon: float | None = None + urls: list[NoteUrl] | None = None class NoteResponse(BaseModel): @@ -40,5 +54,6 @@ class NoteResponse(BaseModel): tags: list[str] gps_lat: float | None gps_lon: float | None + urls: list[NoteUrl] = [] created_at: datetime attachments: list[AttachmentResponse] diff --git a/frontend/package.json b/frontend/package.json index 5d6fec6..aee9085 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "homehub-frontend", "private": true, - "version": "0.5.12", + "version": "0.5.13", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts index 4c998b8..44456ad 100644 --- a/frontend/src/api/notes.ts +++ b/frontend/src/api/notes.ts @@ -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 { diff --git a/frontend/src/components/notes/NoteForm.tsx b/frontend/src/components/notes/NoteForm.tsx index 1f86648..47586db 100644 --- a/frontend/src/components/notes/NoteForm.tsx +++ b/frontend/src/components/notes/NoteForm.tsx @@ -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(initialValues?.urls ?? []) + const [urlLabel, setUrlLabel] = useState('') + const [urlHref, setUrlHref] = useState('') + const [urlError, setUrlError] = useState(null) const [gpsLat, setGpsLat] = useState(initialValues?.gps_lat ?? undefined) const [gpsLon, setGpsLon] = useState(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 /> + {/* URLs */} +
+
Liens
+ + {urls.map((u, idx) => ( +
+ +
+
+ {u.label} +
+
+ {u.url} +
+
+ +
+ ))} + + {/* Formulaire ajout */} +
+
+ setUrlLabel(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())} + /> +
+
+ { setUrlHref(e.target.value); setUrlError(null) }} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())} + type="url" + /> + +
+ {urlError && ( + {urlError} + )} +
+
+ {/* GPS */}
@@ -147,7 +242,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe fontFamily: 'var(--font-ui)', fontSize: 13, minHeight: 36, }} > - + {gpsLoading ? '…' : gpsLat != null ? 'GPS capturé' : 'Ajouter GPS'} {gpsLat != null && ( diff --git a/frontend/src/pages/NotesPage.tsx b/frontend/src/pages/NotesPage.tsx index 64563a7..dda2a42 100644 --- a/frontend/src/pages/NotesPage.tsx +++ b/frontend/src/pages/NotesPage.tsx @@ -256,12 +256,47 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, {images.length > 0 && } {audios.length > 0 && } {videos.length > 0 && } + {note.urls.length > 0 && ( + + + {note.urls.length} + + )} {note.gps_lat != null && ( )}
) + const urlsSection = note.urls.length > 0 ? ( + + ) : null + const mediaSection = ( <> {images.length > 0 && ( @@ -383,6 +418,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, {toggleBtn}
{renderMarkdown(note.content)}
+ {urlsSection} {mediaSection} {metaLine} {actionButtons}