Compare commits

..

5 Commits

Author SHA1 Message Date
gilles 53018c16dd fix(notes): erreur 500 sur les notes antérieures (urls=NULL)
Les notes créées avant la migration 0061 ont urls=NULL en base. Le défaut
NoteResponse.urls=[] ne s'applique qu'à un attribut absent, pas à None, d'où
ResponseValidationError "Input should be a valid list" → GET /api/notes 500
→ "Erreur de chargement" côté UI.

Ajoute un field_validator(mode='before') qui coerce None → [].
Nettoie aussi l'import HttpUrl inutilisé.

v0.5.16

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:15:15 +02:00
gilles 6c889f1561 fix(mcp+alembic): désactive DNS rebinding (421) + rechaîne migrations 006
MCP :
- FastMCP recevait Host=localhost (sans port) mais le pattern par défaut
  allowed_hosts=["localhost:*", ...] EXIGE un port → 421 Invalid Host header
  pour tout accès non-localhost (ex: Hermes via http://10.0.0.50:3001/mcp)
- Désactive enable_dns_rebinding_protection : le Bearer MCP_API_KEY est la
  vraie barrière (protection rebinding = anti-attaque navigateur, inutile ici)
- nginx /mcp : retour à Host $host (le rewrite localhost était cassé)

Alembic :
- Collision : 006_notes_urls et 006_product_tags partageaient revision='006'
  → "Multiple head revisions" au démarrage
- Renumérote notes_urls en 0061, chaîné après product_tags
  Chaîne finale : 005 -> 006 (product_tags) -> 0061 (notes_urls) -> 007

v0.5.15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:07:00 +02:00
gilles 7bf6caa3dd feat(shopping): listes projet + déduplication nommage hebdo
Backend :
- Migration 007 : list_type VARCHAR(20) sur shopping.lists (weekly/project),
  url/description/image_url sur shopping.list_items
- Modèle ShoppingList : champ list_type
- Modèle ListItem : champs url, description, image_url
- Schémas : list_type sur Create/Response, nouveaux champs sur ItemCreate/Update/Response
- _unique_week_label() : évite les doublons S22 2026 → S22 2026 (2)
- finish_shopping : carry-over uniquement pour list_type='weekly'

Frontend :
- api/shopping.ts : list_type, champs enrichis item, createProjectList()
- ProjectItemCard.tsx : carte avec image, description, URL, boutique, cochage
- ShoppingPage :
  · Séparation weekly / project dans la sélection de liste active
  · Section "Listes projet" sur l'écran vide avec navigation
  · Badge PROJET dans l'en-tête
  · Bouton "Clôturer la semaine" et badge "semaine dépassée" masqués sur projet
  · Bouton "+ Ajouter" (mobile + laptop) sur les listes projet
  · Vue grille ProjectItemCard pour les listes projet
  · Modale création liste projet (nom + boutique)
  · Modale ajout/édition item projet (nom, description, URL, image URL)

v0.5.14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:59:53 +02:00
gilles 031708ad8f 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>
2026-05-30 09:47:49 +02:00
gilles b084905226 fix(ui): icône logo TopBar → fa-circle-nodes (cohérence avec icon hub)
Remplace fa-house par fa-circle-nodes dans la TopBar pour aligner
le logo affiché dans l'app avec la nouvelle icône PWA/favicon hub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:42:49 +02:00
17 changed files with 712 additions and 41 deletions
@@ -0,0 +1,30 @@
"""0061 - ajout colonne urls (JSONB) sur notes.items
Revision ID: 0061
Revises: 006
Create Date: 2026-05-30
Note : renumérotée 0061 (au lieu de 006) pour résoudre une collision avec
006_product_tags. Chaînée après product_tags : 005 -> 006 -> 0061 -> 007.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = '0061'
down_revision = '006'
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')
@@ -0,0 +1,32 @@
"""007 - list_type sur shopping.lists, url/description/image_url sur list_items
Revision ID: 007
Revises: 0061
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
revision = '007'
down_revision = '0061'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'lists',
sa.Column('list_type', sa.String(20), nullable=False, server_default='weekly'),
schema='shopping',
)
op.add_column('list_items', sa.Column('url', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('description', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('image_url', sa.String(255), nullable=True), schema='shopping')
def downgrade():
op.drop_column('list_items', 'image_url', schema='shopping')
op.drop_column('list_items', 'description', schema='shopping')
op.drop_column('list_items', 'url', schema='shopping')
op.drop_column('lists', 'list_type', schema='shopping')
+15 -1
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone, date as date_type
from decimal import Decimal
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from sqlalchemy import select, and_, text, or_
from sqlalchemy.orm import selectinload
@@ -15,7 +16,20 @@ from app.models.shopping import ShoppingList, ListItem, Product
_VALID_STATUSES = {"pending", "done", "cancelled"}
_VALID_PRIORITIES = {"low", "medium", "high"}
mcp = FastMCP("HomeHub", stateless_http=True, streamable_http_path="/")
# La protection DNS rebinding (défaut FastMCP) valide le header Host contre
# ["127.0.0.1:*", "localhost:*", "[::1]:*"]. Elle est conçue contre les attaques
# navigateur sur des services localhost. Ici l'accès se fait depuis des agents
# externes (Hermes) via l'IP du serveur, et la vraie barrière est le Bearer token
# MCP_API_KEY (cf. MCPAuthMiddleware). On désactive donc cette protection devenue
# redondante et bloquante (sinon 421 "Invalid Host header" sur toute IP non-localhost).
mcp = FastMCP(
"HomeHub",
stateless_http=True,
streamable_http_path="/",
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),
)
def _serialize(obj):
+27 -5
View File
@@ -27,6 +27,19 @@ def _iso_week_label() -> str:
return f"S{iso[1]} {iso[0]}"
async def _unique_week_label(session: AsyncSession) -> str:
base = _iso_week_label()
existing = (await session.execute(
select(ShoppingList.name).where(ShoppingList.name.like(f"{base}%"))
)).scalars().all()
if base not in existing:
return base
counter = 2
while f"{base} ({counter})" in existing:
counter += 1
return f"{base} ({counter})"
def _item_to_response(item: ListItem) -> ListItemResponse:
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
return ListItemResponse(
@@ -40,6 +53,9 @@ def _item_to_response(item: ListItem) -> ListItemResponse:
price_recorded=item.price_recorded,
carried_over=item.carried_over,
sort_order=item.sort_order,
url=item.url,
description=item.description,
image_url=item.image_url,
)
@@ -48,6 +64,7 @@ def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
return ShoppingListResponse(
id=lst.id,
name=lst.name,
list_type=lst.list_type,
store_id=lst.store_id,
week_date=lst.week_date,
status=lst.status,
@@ -167,8 +184,8 @@ async def create_shopping_list(
session: AsyncSession = Depends(get_session),
):
data = payload.model_dump()
if not data.get('name'):
data['name'] = _iso_week_label()
if not data.get('name') and data.get('list_type', 'weekly') == 'weekly':
data['name'] = await _unique_week_label(session)
lst = ShoppingList(**data)
session.add(lst)
await session.commit()
@@ -380,7 +397,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
result = await session.execute(query)
rows = result.mappings().all()
new_list = ShoppingList(name=_iso_week_label(), status="draft")
new_list = ShoppingList(name=await _unique_week_label(session), list_type="weekly", status="draft")
session.add(new_list)
await session.flush()
@@ -425,8 +442,13 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
lst.status = "done"
unchecked = [i for i in lst.items if not i.is_checked]
if unchecked:
new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label())
if unchecked and lst.list_type == 'weekly':
new_list = ShoppingList(
store_id=lst.store_id,
list_type="weekly",
status="draft",
name=await _unique_week_label(session),
)
session.add(new_list)
await session.flush()
for item in unchecked:
+1
View File
@@ -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))
+4
View File
@@ -63,6 +63,7 @@ class ShoppingList(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str | None] = mapped_column(String(100))
list_type: Mapped[str] = mapped_column(String(20), server_default="weekly")
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
week_date: Mapped[date | None] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(20), server_default="draft")
@@ -86,6 +87,9 @@ class ListItem(Base):
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
sort_order: Mapped[int | None] = mapped_column(Integer)
url: Mapped[str | None] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(String(255))
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
product: Mapped["Product | None"] = relationship("Product", lazy="select")
+22 -1
View File
@@ -1,6 +1,18 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, 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,12 @@ class NoteResponse(BaseModel):
tags: list[str]
gps_lat: float | None
gps_lon: float | None
urls: list[NoteUrl] = []
created_at: datetime
attachments: list[AttachmentResponse]
@field_validator('urls', mode='before')
@classmethod
def coerce_urls(cls, v: object) -> object:
# Les notes antérieures à la migration 0061 ont urls=NULL en base.
return v or []
+18
View File
@@ -83,6 +83,9 @@ class ListItemCreate(BaseModel):
custom_name: str | None = None
quantity: Decimal | None = None
unit: str | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
@model_validator(mode='after')
def must_have_name(self) -> 'ListItemCreate':
@@ -96,6 +99,9 @@ class ListItemUpdate(BaseModel):
quantity: Decimal | None = None
unit: str | None = None
price_recorded: Decimal | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
class ListItemResponse(BaseModel):
@@ -110,13 +116,23 @@ class ListItemResponse(BaseModel):
price_recorded: Decimal | None
carried_over: bool
sort_order: int | None
url: str | None
description: str | None
image_url: str | None
class ShoppingListCreate(BaseModel):
name: str | None = None
list_type: Literal['weekly', 'project'] = 'weekly'
store_id: uuid.UUID | None = None
week_date: date | None = None
@model_validator(mode='after')
def project_requires_name(self) -> 'ShoppingListCreate':
if self.list_type == 'project' and not self.name:
raise ValueError('Une liste projet doit avoir un nom')
return self
class ShoppingListUpdate(BaseModel):
name: str | None = None
@@ -128,6 +144,7 @@ class ShoppingListResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str
@@ -140,6 +157,7 @@ class ShoppingListDetailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str
+1 -1
View File
@@ -27,7 +27,7 @@ server {
location /mcp {
proxy_pass http://backend:8000/mcp;
proxy_set_header Host localhost;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.5.11",
"version": "0.5.16",
"type": "module",
"scripts": {
"dev": "vite",
+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 {
+15
View File
@@ -82,11 +82,15 @@ export interface ShoppingItem {
price_recorded: string | null
carried_over: boolean
sort_order: number | null
url: string | null
description: string | null
image_url: string | null
}
export interface ShoppingList {
id: string
name: string | null
list_type: 'weekly' | 'project'
store_id: string | null
week_date: string | null
status: 'draft' | 'active' | 'done'
@@ -101,6 +105,7 @@ export interface ShoppingListDetail extends ShoppingList {
export interface ShoppingListCreate {
name?: string
list_type?: 'weekly' | 'project'
store_id?: string
week_date?: string
}
@@ -116,6 +121,9 @@ export interface ShoppingItemCreate {
custom_name?: string
quantity?: string
unit?: string
url?: string
description?: string
image_url?: string
}
export interface ShoppingItemUpdate {
@@ -123,6 +131,9 @@ export interface ShoppingItemUpdate {
quantity?: string
unit?: string
price_recorded?: string
url?: string
description?: string
image_url?: string
}
const BASE = '/api/shopping'
@@ -201,6 +212,10 @@ export async function createList(data: ShoppingListCreate): Promise<ShoppingList
}))
}
export async function createProjectList(name: string, storeId?: string): Promise<ShoppingListDetail> {
return createList({ name, list_type: 'project', store_id: storeId })
}
export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
return handleResponse(await fetch(`${BASE}/lists/${id}`))
}
+1 -1
View File
@@ -38,7 +38,7 @@ function TopBar() {
padding: '0 12px', gap: 8,
}}>
{/* Identité app — gauche */}
<i className="fa-solid fa-house" style={{ color: 'var(--accent)', fontSize: 16, flexShrink: 0 }} />
<i className="fa-solid fa-circle-nodes" style={{ color: 'var(--accent)', fontSize: 16, flexShrink: 0 }} />
<span style={{
fontFamily: 'var(--font-mono)', fontWeight: 700,
color: 'var(--accent)', fontSize: 15, letterSpacing: '-0.02em',
+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 && (
@@ -0,0 +1,125 @@
import { useState } from 'react'
import type { ShoppingItem, Store } from '../../api/shopping'
interface ProjectItemCardProps {
item: ShoppingItem
stores: Store[]
onCheck: () => void
onDelete: () => void
onEdit: () => void
}
export default function ProjectItemCard({ item, stores, onCheck, onDelete, onEdit }: ProjectItemCardProps) {
const [imgError, setImgError] = useState(false)
const store = stores.find(s => s.id === item.product_id)
return (
<div style={{
background: item.is_checked ? 'rgba(142,192,124,0.08)' : 'var(--bg-3)',
borderRadius: 10,
overflow: 'hidden',
border: '1px solid var(--bg-4)',
opacity: item.is_checked ? 0.65 : 1,
transition: 'opacity 0.2s',
}}>
{/* Image */}
{item.image_url && !imgError && (
<div style={{ position: 'relative', height: 140, overflow: 'hidden', background: 'var(--bg-2)' }}>
<img
src={item.image_url}
alt={item.display_name}
onError={() => setImgError(true)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
)}
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Nom + actions */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button
onClick={onCheck}
style={{
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${item.is_checked ? 'var(--ok)' : 'var(--bg-5)'}`,
background: item.is_checked ? 'var(--ok)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'all 0.15s', marginTop: 2,
}}
>
{item.is_checked && <span style={{ color: '#1d2021', fontSize: 13, fontWeight: 700 }}></span>}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: item.is_checked ? 'var(--ink-3)' : 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 15, fontWeight: 600,
textDecoration: item.is_checked ? 'line-through' : 'none',
overflowWrap: 'anywhere',
}}>
{item.display_name}
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
onClick={onEdit}
style={{ background: 'var(--bg-4)', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-3)', fontSize: 13 }}
title="Modifier"
><i className="fa-solid fa-pen" /></button>
<button
onClick={onDelete}
style={{ background: 'transparent', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-4)', fontSize: 13 }}
title="Supprimer"
><i className="fa-solid fa-xmark" /></button>
</div>
</div>
{/* Description */}
{item.description && (
<p style={{
margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)',
fontSize: 13, lineHeight: 1.5, overflowWrap: 'anywhere',
}}>
{item.description}
</p>
)}
{/* Meta : boutique + lien */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{store && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'var(--bg-4)', borderRadius: 999, padding: '2px 8px',
color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)',
}}>
<i className="fa-solid fa-store" style={{ fontSize: 9 }} />
{store.name}
</span>
)}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
color: 'var(--info)', fontFamily: 'var(--font-ui)', fontSize: 12,
textDecoration: 'none',
}}
>
<i className="fa-solid fa-arrow-up-right-from-square" style={{ fontSize: 10 }} />
Voir le produit
</a>
)}
{item.quantity && (
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
</span>
)}
</div>
</div>
</div>
)
}
+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}
+280 -29
View File
@@ -4,13 +4,14 @@ import { useServerEvents } from '../hooks/useServerEvents'
import { matchesSearch } from '../utils/search'
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
import {
fetchLists, createList, fetchListDetail, deleteList,
fetchLists, createList, createProjectList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
searchProducts, createProduct,
} from '../api/shopping'
import Modal from '../components/Modal'
import BottomSheet from '../components/BottomSheet'
import ItemRow from '../components/shopping/ItemRow'
import ProjectItemCard from '../components/shopping/ProjectItemCard'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock'
@@ -132,7 +133,8 @@ export default function ShoppingPage() {
setStores(storesData)
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
const current = listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'weekly')
?? listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
if (current) {
setCurrentList(await fetchListDetail(current.id))
} else {
@@ -353,6 +355,81 @@ export default function ShoppingPage() {
}
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
const [showNewProjectModal, setShowNewProjectModal] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectStoreId, setNewProjectStoreId] = useState('')
const [projectCreating, setProjectCreating] = useState(false)
const [showProjectItemModal, setShowProjectItemModal] = useState(false)
const [editingProjectItem, setEditingProjectItem] = useState<ShoppingItem | null>(null)
const [projItemName, setProjItemName] = useState('')
const [projItemDesc, setProjItemDesc] = useState('')
const [projItemUrl, setProjItemUrl] = useState('')
const [projItemImageUrl, setProjItemImageUrl] = useState('')
const [projItemStoreId, setProjItemStoreId] = useState('')
const [projItemSaving, setProjItemSaving] = useState(false)
async function handleCreateProject() {
if (!newProjectName.trim()) return
setProjectCreating(true)
try {
const detail = await createProjectList(newProjectName.trim(), newProjectStoreId || undefined)
setCurrentList(detail)
setShowNewProjectModal(false)
setNewProjectName('')
setNewProjectStoreId('')
void loadData()
} catch {
setError('Erreur lors de la création')
} finally {
setProjectCreating(false)
}
}
function openProjectItemModal(item?: ShoppingItem) {
if (item) {
setEditingProjectItem(item)
setProjItemName(item.display_name)
setProjItemDesc(item.description ?? '')
setProjItemUrl(item.url ?? '')
setProjItemImageUrl(item.image_url ?? '')
setProjItemStoreId('')
} else {
setEditingProjectItem(null)
setProjItemName('')
setProjItemDesc('')
setProjItemUrl('')
setProjItemImageUrl('')
setProjItemStoreId('')
}
setShowProjectItemModal(true)
}
async function handleSaveProjectItem() {
if (!currentList || !projItemName.trim()) return
setProjItemSaving(true)
try {
if (editingProjectItem) {
await updateItem(currentList.id, editingProjectItem.id, {
url: projItemUrl.trim() || undefined,
description: projItemDesc.trim() || undefined,
image_url: projItemImageUrl.trim() || undefined,
})
} else {
await addItem(currentList.id, {
custom_name: projItemName.trim(),
description: projItemDesc.trim() || undefined,
url: projItemUrl.trim() || undefined,
image_url: projItemImageUrl.trim() || undefined,
})
}
setShowProjectItemModal(false)
void refreshCurrentList()
} catch {
setError("Erreur lors de l'enregistrement")
} finally {
setProjItemSaving(false)
}
}
async function handleFinish() {
if (!currentList) return
@@ -397,7 +474,9 @@ export default function ShoppingPage() {
const uncheckedItems = sortedItems.filter(i => !i.is_checked)
const checkedItems = sortedItems.filter(i => i.is_checked)
const hasCurrentList = currentList !== null
const pastLists = allLists.filter(l => l.status === 'done')
const isProjectList = currentList?.list_type === 'project'
const pastLists = allLists.filter(l => l.status === 'done' && l.list_type === 'weekly')
const activeProjectLists = allLists.filter(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
const filteredProducts = products.filter(p => {
const term = itemSearch.trim()
@@ -418,8 +497,13 @@ export default function ShoppingPage() {
borderBottom: '1px solid var(--bg-4)',
position: 'sticky', top: 0, zIndex: 10,
}}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect, display: 'flex', alignItems: 'center', gap: 8 }}>
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
{isProjectList && (
<span style={{ fontSize: 10, background: 'var(--info)', color: '#fff', borderRadius: 999, padding: '2px 7px', fontFamily: 'var(--font-ui)', fontWeight: 600, letterSpacing: 0.3 }}>
PROJET
</span>
)}
</h1>
<button
onClick={() => setShowCatalogueModal(true)}
@@ -439,7 +523,7 @@ export default function ShoppingPage() {
...noSelect,
}}
>Boutiques</button>
{hasCurrentList && (
{hasCurrentList && !isProjectList && (
<button
className="hidden lg:flex"
onClick={openAddSheet}
@@ -454,6 +538,21 @@ export default function ShoppingPage() {
<i className="fa-solid fa-cart-plus" /> Article
</button>
)}
{hasCurrentList && isProjectList && (
<button
className="hidden lg:flex"
onClick={() => openProjectItemModal()}
style={{
alignItems: 'center', gap: 8,
background: 'var(--accent)', border: 'none',
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
...noSelect,
}}
>
<i className="fa-solid fa-plus" /> Ajouter
</button>
)}
</div>
{/* ── Erreur ── */}
@@ -520,6 +619,38 @@ export default function ShoppingPage() {
}}
>Voir l'historique ({pastLists.length})</button>
)}
{/* Séparateur + listes projet */}
<div style={{ width: '100%', borderTop: '1px solid var(--bg-4)', paddingTop: 8 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, ...noSelect }}>
Listes projet
</span>
<button
onClick={() => setShowNewProjectModal(true)}
style={{ background: 'var(--bg-3)', border: '1px solid var(--bg-5)', borderRadius: 8, color: 'var(--ink-2)', cursor: 'pointer', padding: '4px 10px', fontFamily: 'var(--font-ui)', fontSize: 12, ...noSelect }}
>+ Nouveau projet</button>
</div>
{activeProjectLists.length === 0 && (
<p style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 13, margin: 0, textAlign: 'center', ...noSelect }}>Aucun projet en cours</p>
)}
{activeProjectLists.map(list => (
<div
key={list.id}
onClick={() => void fetchListDetail(list.id).then(d => { setCurrentList(d) })}
className="glass interactive"
style={{ borderRadius: 8, padding: '10px 14px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}
>
<i className="fa-solid fa-bag-shopping" style={{ color: 'var(--info)', fontSize: 14, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, ...noSelect }}>{list.name}</div>
<div style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)', ...noSelect }}>{list.item_count} article{list.item_count > 1 ? 's' : ''}</div>
</div>
<span style={{ color: 'var(--ink-3)', fontSize: 16 }}>→</span>
</div>
))}
</div>
</div>
)}
@@ -537,7 +668,7 @@ export default function ShoppingPage() {
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{checkedItems.length}/{currentList.item_count} cochés
</span>
{isListOutdated(currentList.name) && (
{!isProjectList && isListOutdated(currentList.name) && (
<span
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
style={{
@@ -563,18 +694,35 @@ export default function ShoppingPage() {
onClick={() => void handleDeleteCurrentList()}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer</button>
<button
onClick={() => setShowFinishConfirm(true)}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
<i className="fa-solid fa-check" /> Clôturer la semaine
</button>
{!isProjectList && (
<button
onClick={() => setShowFinishConfirm(true)}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
<i className="fa-solid fa-check" /> Clôturer la semaine
</button>
)}
{isProjectList && (
<button
onClick={() => openProjectItemModal()}
className="flex lg:hidden"
style={{
background: 'var(--accent)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
alignItems: 'center', gap: 6,
}}
>
<i className="fa-solid fa-plus" /> Ajouter
</button>
)}
</div>
{/* Articles non cochés */}
@@ -584,17 +732,32 @@ export default function ShoppingPage() {
</p>
)}
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64 }}>
{uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))}
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64, padding: isProjectList ? '12px 16px 64px' : '0 0 64px' }}>
{isProjectList ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{sortedItems.map(item => (
<ProjectItemCard
key={item.id}
item={item}
stores={stores}
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openProjectItemModal(item)}
/>
))}
</div>
) : (
uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))
)}
{checkedItems.length > 0 && (
<>
@@ -886,6 +1049,94 @@ export default function ShoppingPage() {
</div>
</Modal>
)}
{/* Modale création liste projet */}
{showNewProjectModal && (
<Modal title="Nouveau projet d'achat" onClose={() => setShowNewProjectModal(false)} width={420}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
style={inputStyle}
placeholder="Nom du projet (ex: RAM pour PC)"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleCreateProject()}
/>
<select
style={inputStyle}
value={newProjectStoreId}
onChange={e => setNewProjectStoreId(e.target.value)}
>
<option value="">Boutique (optionnel)</option>
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowNewProjectModal(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleCreateProject()}
disabled={!newProjectName.trim() || projectCreating}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projectCreating ? 0.7 : 1 }}
>{projectCreating ? '…' : 'Créer'}</button>
</div>
</div>
</Modal>
)}
{/* Modale ajout/édition item projet */}
{showProjectItemModal && (
<Modal
title={editingProjectItem ? `Modifier — ${editingProjectItem.display_name}` : 'Ajouter un article'}
onClose={() => setShowProjectItemModal(false)}
width={480}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!editingProjectItem && (
<input
style={inputStyle}
placeholder="Nom de l'article *"
value={projItemName}
onChange={e => setProjItemName(e.target.value)}
autoFocus
/>
)}
<textarea
style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }}
placeholder="Description (optionnel)"
value={projItemDesc}
onChange={e => setProjItemDesc(e.target.value)}
autoFocus={!!editingProjectItem}
/>
<input
style={inputStyle}
placeholder="Lien URL (ex: https://amazon.fr/...)"
value={projItemUrl}
onChange={e => setProjItemUrl(e.target.value)}
type="url"
/>
<input
style={inputStyle}
placeholder="Image URL (ex: https://.../.jpg)"
value={projItemImageUrl}
onChange={e => setProjItemImageUrl(e.target.value)}
type="url"
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowProjectItemModal(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleSaveProjectItem()}
disabled={(!editingProjectItem && !projItemName.trim()) || projItemSaving}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projItemSaving ? 0.7 : 1 }}
>{projItemSaving ? '…' : 'Enregistrer'}</button>
</div>
</div>
</Modal>
)}
</div>
)
}