Compare commits
10 Commits
208af72344
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cfd8184e9 | |||
| 59b5836fbd | |||
| 091eead5bb | |||
| 36b5760566 | |||
| c36be15e18 | |||
| 53018c16dd | |||
| 6c889f1561 | |||
| 7bf6caa3dd | |||
| 031708ad8f | |||
| b084905226 |
+27
-4
@@ -1,4 +1,27 @@
|
||||
DATABASE_URL=postgresql+asyncpg://homehub:homehub@db:5432/homehub
|
||||
UPLOAD_DIR=/uploads
|
||||
CORS_ORIGINS=http://localhost:3001,http://localhost:3000
|
||||
MCP_API_KEY=
|
||||
# ── HomeHub — variables d'environnement ──
|
||||
# Copier en .env et compléter. Ne JAMAIS committer le .env réel.
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=homehub
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=homehub
|
||||
|
||||
# Connexion backend (alignée sur les valeurs PostgreSQL ci-dessus)
|
||||
DATABASE_URL=postgresql+asyncpg://homehub:change-me@db:5432/homehub
|
||||
|
||||
# Stockage médias / données
|
||||
UPLOAD_DIR=/data/uploads
|
||||
DATA_DIR=/data
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# CORS — réseau local autorisé (séparés par des virgules)
|
||||
CORS_ORIGINS=http://localhost:3001,http://10.0.0.50:3001,http://10.0.1.137:3001,http://10.0.1.65:3001,http://10.0.1.45:3001
|
||||
|
||||
|
||||
# Clé Bearer du serveur MCP (générer une valeur aléatoire forte)
|
||||
MCP_API_KEY=4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI
|
||||
|
||||
# Port exposé du frontend (déploiement)
|
||||
FRONTEND_PORT=3001
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Build & Push OCI
|
||||
|
||||
# Construit et publie les images HomeHub sur le registre OCI Gitea.
|
||||
# - push sur main -> tags <role>-latest
|
||||
# - tag git vX.Y.Z -> tags <role>-latest + <role>-X.Y.Z
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
REGISTRY: git.maison43gil.com
|
||||
IMAGE: git.maison43gil.com/gilles/home_hub
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- role: backend
|
||||
context: ./backend
|
||||
- role: frontend
|
||||
context: ./frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login au registre OCI Gitea
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Déduire les tags
|
||||
id: tags
|
||||
run: |
|
||||
TAGS="${IMAGE}:${{ matrix.role }}-latest"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAGS="${TAGS},${IMAGE}:${{ matrix.role }}-${VERSION}"
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build & push ${{ matrix.role }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
@@ -0,0 +1,261 @@
|
||||
# Consigne d'utilisation du Registry OCI Gitea
|
||||
|
||||
## Objectif
|
||||
|
||||
Toutes les images Docker finales d'un projet doivent être stockées dans le registre OCI privé de Gitea afin de centraliser :
|
||||
|
||||
- le code source ;
|
||||
- les versions applicatives ;
|
||||
- les images Docker ;
|
||||
- les déploiements ;
|
||||
- l'historique des versions.
|
||||
|
||||
Le registre OCI officiel à utiliser est :
|
||||
|
||||
```text
|
||||
git.maison43gil.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Règle générale
|
||||
|
||||
Une image Docker finale ne doit jamais être publiée sur :
|
||||
|
||||
- Docker Hub ;
|
||||
- GitHub Container Registry ;
|
||||
- Quay ;
|
||||
- GitLab Registry ;
|
||||
- tout autre registre externe.
|
||||
|
||||
Toutes les images doivent être publiées sur :
|
||||
|
||||
```text
|
||||
git.maison43gil.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Convention de nommage
|
||||
|
||||
Le nom de l'image doit correspondre au nom du dépôt Git.
|
||||
|
||||
Exemple :
|
||||
|
||||
```text
|
||||
Dépôt Git :
|
||||
home_hub
|
||||
|
||||
Image OCI :
|
||||
git.maison43gil.com/gilles/home_hub:latest
|
||||
```
|
||||
|
||||
Autres exemples :
|
||||
|
||||
```text
|
||||
git.maison43gil.com/gilles/design_system:latest
|
||||
git.maison43gil.com/gilles/nano_metrics:latest
|
||||
git.maison43gil.com/gilles/esp_jardin:latest
|
||||
git.maison43gil.com/gilles/proxmenus:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Tags à publier
|
||||
|
||||
Minimum :
|
||||
|
||||
```text
|
||||
latest
|
||||
```
|
||||
|
||||
Recommandé :
|
||||
|
||||
```text
|
||||
latest
|
||||
v1.0.0
|
||||
v1.1.0
|
||||
v2.0.0
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```text
|
||||
git.maison43gil.com/gilles/home_hub:latest
|
||||
git.maison43gil.com/gilles/home_hub:v1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Structure attendue d'un projet
|
||||
|
||||
```text
|
||||
projet/
|
||||
├── src/
|
||||
├── Dockerfile
|
||||
├── docker-compose.dev.yml
|
||||
├── docker-compose.deploy.yml
|
||||
├── README.md
|
||||
├── .env.example
|
||||
└── .gitea/
|
||||
└── workflows/
|
||||
└── build.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Dockerfile
|
||||
|
||||
Le Dockerfile doit générer une image autonome et prête au déploiement.
|
||||
|
||||
L'image finale doit :
|
||||
|
||||
- être optimisée ;
|
||||
- ne contenir que les composants nécessaires ;
|
||||
- ne pas contenir de secrets ;
|
||||
- être exécutable sans modification.
|
||||
|
||||
---
|
||||
|
||||
# Construction de l'image
|
||||
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
-t git.maison43gil.com/gilles/home_hub:latest .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Publication de l'image
|
||||
|
||||
Authentification :
|
||||
|
||||
```bash
|
||||
docker login git.maison43gil.com
|
||||
```
|
||||
|
||||
Publication :
|
||||
|
||||
```bash
|
||||
docker push \
|
||||
git.maison43gil.com/gilles/home_hub:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Déploiement
|
||||
|
||||
Le fichier de déploiement doit utiliser exclusivement l'image OCI publiée.
|
||||
|
||||
Exemple :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
||||
home_hub:
|
||||
|
||||
image: git.maison43gil.com/gilles/home_hub:latest
|
||||
|
||||
container_name: home_hub
|
||||
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Le déploiement ne doit pas reconstruire l'image.
|
||||
|
||||
Interdiction :
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
```
|
||||
|
||||
dans les fichiers de production.
|
||||
|
||||
---
|
||||
|
||||
# Séparation développement / production
|
||||
|
||||
Développement :
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
```
|
||||
|
||||
autorisé.
|
||||
|
||||
Production :
|
||||
|
||||
```yaml
|
||||
image:
|
||||
```
|
||||
|
||||
obligatoire.
|
||||
|
||||
Exemple :
|
||||
|
||||
```text
|
||||
docker-compose.dev.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```text
|
||||
docker-compose.deploy.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: git.maison43gil.com/gilles/app:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Processus standard
|
||||
|
||||
```text
|
||||
Développement
|
||||
↓
|
||||
Tests
|
||||
↓
|
||||
docker build
|
||||
↓
|
||||
docker push
|
||||
↓
|
||||
Registry OCI Gitea
|
||||
↓
|
||||
docker compose pull
|
||||
↓
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Consigne pour un agent IA
|
||||
|
||||
Lorsqu'un projet doit être conteneurisé :
|
||||
|
||||
1. Créer un Dockerfile.
|
||||
2. Construire une image Docker finale.
|
||||
3. Nommer l'image selon le dépôt Git.
|
||||
4. Publier l'image dans le registre OCI Gitea.
|
||||
5. Générer un docker-compose.deploy.yml utilisant uniquement l'image publiée.
|
||||
6. Ne jamais utiliser Docker Hub comme destination finale.
|
||||
7. Ne jamais utiliser `build:` dans les fichiers de production.
|
||||
8. Toujours privilégier :
|
||||
|
||||
```text
|
||||
git.maison43gil.com/gilles/<nom_du_projet>:latest
|
||||
```
|
||||
|
||||
comme source officielle de déploiement.
|
||||
|
||||
Le registre OCI Gitea constitue la source unique de vérité pour toutes les images Docker de production.
|
||||
@@ -121,6 +121,64 @@ docker compose up -d
|
||||
# homehub.local/mcp → backend:8000/mcp (pour les agents IA)
|
||||
```
|
||||
|
||||
## Déploiement via registre OCI Gitea
|
||||
|
||||
Les images finales sont publiées sur le registre OCI privé `git.maison43gil.com`
|
||||
(source unique de vérité). Deux images sous le paquet `home_hub`, distinguées par tag :
|
||||
|
||||
| Image | Tag |
|
||||
|-------|-----|
|
||||
| Backend (FastAPI) | `git.maison43gil.com/gilles/home_hub:backend-latest` |
|
||||
| Frontend (Nginx) | `git.maison43gil.com/gilles/home_hub:frontend-latest` |
|
||||
|
||||
### Construction & publication (manuel)
|
||||
|
||||
```bash
|
||||
docker login git.maison43gil.com
|
||||
|
||||
# Backend
|
||||
docker build -t git.maison43gil.com/gilles/home_hub:backend-latest ./backend
|
||||
docker push git.maison43gil.com/gilles/home_hub:backend-latest
|
||||
|
||||
# Frontend
|
||||
docker build -t git.maison43gil.com/gilles/home_hub:frontend-latest ./frontend
|
||||
docker push git.maison43gil.com/gilles/home_hub:frontend-latest
|
||||
```
|
||||
|
||||
> Le registre est derrière Nginx Proxy Manager : pour les gros blobs (image
|
||||
> backend ~1.3 Go), régler `client_max_body_size 0;` dans l'onglet Advanced
|
||||
> du proxy host `git.maison43gil.com` (sinon erreur `413 Payload Too Large`).
|
||||
|
||||
### Construction automatique (Gitea Actions)
|
||||
|
||||
`.gitea/workflows/build.yml` build et push les deux images :
|
||||
- push sur `main` → tags `*-latest`
|
||||
- tag git `vX.Y.Z` → tags `*-latest` + `*-X.Y.Z`
|
||||
|
||||
### Déploiement sur le serveur
|
||||
|
||||
```bash
|
||||
# Sur le serveur de production
|
||||
git pull # récupère docker-compose.deploy.yml + .env.example
|
||||
cp .env.example .env # compléter les secrets (POSTGRES_PASSWORD, MCP_API_KEY…)
|
||||
|
||||
docker login git.maison43gil.com
|
||||
docker compose -f docker-compose.deploy.yml pull
|
||||
docker compose -f docker-compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
Le service `backend-migrate` applique automatiquement les migrations Alembic
|
||||
(`alembic upgrade head`) avant le démarrage du backend. Aucune image n'est
|
||||
reconstruite en production (pas de `build:`).
|
||||
|
||||
### Environnements
|
||||
|
||||
| Fichier | Usage | Images |
|
||||
|---------|-------|--------|
|
||||
| `docker-compose.yml` | Dev (build local) | `build:` |
|
||||
| `docker-compose.dev.yml` | Override dev (HMR + `--reload`) | `build:` |
|
||||
| `docker-compose.deploy.yml` | **Production** | `image:` (OCI Gitea) |
|
||||
|
||||
## Évolutions prévues
|
||||
|
||||
- **Phase 5** — Scan code-barres (zxing-js) + enrichissement catalogue via OpenFoodFacts
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
tests/
|
||||
.env
|
||||
.env.*
|
||||
*.dump
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@@ -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')
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Environnement de PRODUCTION — images publiées sur le registre OCI Gitea.
|
||||
# Aucune image n'est reconstruite ici (pas de build:). Source unique de vérité :
|
||||
# git.maison43gil.com/gilles/home_hub
|
||||
#
|
||||
# Utilisation :
|
||||
# docker login git.maison43gil.com
|
||||
# docker compose -f docker-compose.deploy.yml pull
|
||||
# docker compose -f docker-compose.deploy.yml up -d
|
||||
#
|
||||
# Les secrets proviennent du fichier .env (voir .env.example).
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: home_hub_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-homehub}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-homehub}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-homehub}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: home_hub_redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Applique les migrations Alembic puis se termine. Le backend attend son succès.
|
||||
backend-migrate:
|
||||
image: git.maison43gil.com/gilles/home_hub:backend-latest
|
||||
container_name: home_hub_migrate
|
||||
user: "1000:1000"
|
||||
command: alembic upgrade head
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
backend:
|
||||
image: git.maison43gil.com/gilles/home_hub:backend-latest
|
||||
container_name: home_hub_backend
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
|
||||
UPLOAD_DIR: /data/uploads
|
||||
DATA_DIR: /data
|
||||
REDIS_URL: redis://redis:6379
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3001}
|
||||
MCP_API_KEY: ${MCP_API_KEY:?MCP_API_KEY requis}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
backend-worker:
|
||||
image: git.maison43gil.com/gilles/home_hub:backend-latest
|
||||
container_name: home_hub_worker
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
command: arq app.workers.notes_worker.WorkerSettings
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
|
||||
UPLOAD_DIR: /data/uploads
|
||||
DATA_DIR: /data
|
||||
REDIS_URL: redis://redis:6379
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
frontend:
|
||||
image: git.maison43gil.com/gilles/home_hub:frontend-latest
|
||||
container_name: home_hub_frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3001}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.vite/
|
||||
+1
-1
@@ -4,12 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#fe8019" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>HomeHub</title>
|
||||
<!-- Anti-flash : applique thème et zoom avant le premier rendu -->
|
||||
<script>
|
||||
|
||||
+1
-1
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.11",
|
||||
"version": "0.5.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user