This commit is contained in:
2026-03-01 07:21:46 +01:00
parent 9db5cbf236
commit 7967f63fea
39 changed files with 3297 additions and 1646 deletions

View File

@@ -75,7 +75,9 @@
"Bash(docker compose restart:*)",
"Bash(docker compose build:*)",
"Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")",
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)"
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)",
"Bash(npx tsc:*)",
"Bash(npx vite:*)"
],
"additionalDirectories": [
"/home/gilles/Documents/vscode/jardin/frontend/src",

View File

@@ -1,3 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
@@ -23,15 +24,22 @@ async def lifespan(app: FastAPI):
from app.seed import run_seed
run_seed()
if ENABLE_SCHEDULER:
from app.services.scheduler import setup_scheduler
from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
setup_scheduler()
# Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage)
loop = asyncio.get_running_loop()
loop.run_in_executor(None, backfill_station_missing_dates)
yield
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
from app.services.scheduler import scheduler
scheduler.shutdown(wait=False)
app = FastAPI(title="Jardin API", lifespan=lifespan)
app = FastAPI(
title="Jardin API",
lifespan=lifespan,
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.3/bundles/redoc.standalone.js"
)
app.add_middleware(
CORSMiddleware,

View File

@@ -51,6 +51,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("boutique_url", "TEXT", None),
("tarif_achat", "REAL", None),
("date_achat", "TEXT", None),
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
],
"plantvariety": [
# ancien nom de table → migration vers "plant" si présente

View File

@@ -1,5 +1,7 @@
from datetime import date, datetime, timezone
from typing import Optional
from typing import List, Optional
from sqlalchemy import Column
from sqlalchemy import JSON as SA_JSON
from sqlmodel import Field, SQLModel
@@ -7,6 +9,7 @@ class PlantingCreate(SQLModel):
garden_id: int
variety_id: int
cell_id: Optional[int] = None
cell_ids: Optional[List[int]] = None # multi-sélect zones
date_semis: Optional[date] = None
date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None
@@ -28,6 +31,10 @@ class Planting(SQLModel, table=True):
garden_id: int = Field(foreign_key="garden.id", index=True)
variety_id: int = Field(foreign_key="plant.id", index=True)
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
cell_ids: Optional[List[int]] = Field(
default=None,
sa_column=Column("cell_ids", SA_JSON, nullable=True),
)
date_semis: Optional[date] = None
date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None

View File

@@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
return cell
@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell)
def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)):
c = session.get(GardenCell, cell_id)
if not c or c.garden_id != id:
raise HTTPException(status_code=404, detail="Case introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items():
setattr(c, k, v)
session.add(c)
session.commit()
session.refresh(c)
return c
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
def list_measurements(id: int, session: Session = Depends(get_session)):
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()

View File

@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
p = Planting(**data.model_dump())
d = data.model_dump()
# Rétro-compatibilité : cell_id = première zone sélectionnée
if d.get("cell_ids") and not d.get("cell_id"):
d["cell_id"] = d["cell_ids"][0]
p = Planting(**d)
session.add(p)
session.commit()
session.refresh(p)
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
p = session.get(Planting, id)
if not p:
raise HTTPException(status_code=404, detail="Plantation introuvable")
for k, v in data.model_dump(exclude_unset=True).items():
d = data.model_dump(exclude_unset=True)
# Rétro-compatibilité : cell_id = première zone sélectionnée
if "cell_ids" in d:
ids = d["cell_ids"] or []
d["cell_id"] = ids[0] if ids else None
for k, v in d.items():
setattr(p, k, v)
p.updated_at = datetime.now(timezone.utc)
session.add(p)

View File

@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
logger.info(f"Open-Meteo stocké : {len(rows)} jours")
def backfill_station_missing_dates(max_days_back: int = 365) -> None:
"""Remplit les dates manquantes de la station météo au démarrage.
Cherche toutes les dates sans entrée « veille » dans meteostation
depuis max_days_back jours en arrière jusqu'à hier (excl. aujourd'hui),
puis télécharge les fichiers NOAA mois par mois pour remplir les trous.
Un seul appel HTTP par mois manquant.
"""
from datetime import date, timedelta
from itertools import groupby
from app.services.station import fetch_month_summaries
from app.models.meteo import MeteoStation
from app.database import engine
from sqlmodel import Session, select
today = date.today()
start_date = today - timedelta(days=max_days_back)
# 1. Dates « veille » déjà présentes en BDD
with Session(engine) as session:
rows = session.exec(
select(MeteoStation.date_heure).where(MeteoStation.type == "veille")
).all()
existing_dates: set[str] = {dh[:10] for dh in rows}
# 2. Dates manquantes entre start_date et hier (aujourd'hui exclu)
missing: list[date] = []
cursor = start_date
while cursor < today:
if cursor.isoformat() not in existing_dates:
missing.append(cursor)
cursor += timedelta(days=1)
if not missing:
logger.info("Backfill station : aucune date manquante")
return
logger.info(f"Backfill station : {len(missing)} date(s) manquante(s) à récupérer")
# 3. Grouper par (année, mois) → 1 requête HTTP par mois
def month_key(d: date) -> tuple[int, int]:
return (d.year, d.month)
filled = 0
for (year, month), group_iter in groupby(sorted(missing), key=month_key):
month_data = fetch_month_summaries(year, month)
if not month_data:
logger.debug(f"Backfill station : pas de données NOAA pour {year}-{month:02d}")
continue
with Session(engine) as session:
for d in group_iter:
data = month_data.get(d.day)
if not data:
continue
date_heure = f"{d.isoformat()}T00:00"
if not session.get(MeteoStation, date_heure):
session.add(MeteoStation(date_heure=date_heure, type="veille", **data))
filled += 1
session.commit()
logger.info(f"Backfill station terminé : {filled} date(s) insérée(s)")
def setup_scheduler() -> None:
"""Configure et démarre le scheduler."""
scheduler.add_job(

View File

@@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
return None
def _parse_noaa_day_line(parts: list[str]) -> dict | None:
"""Parse une ligne de données journalières du fichier NOAA WeeWX.
Format standard : day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
"""
if not parts or not parts[0].isdigit():
return None
# Format complet avec timestamps hh:mm en positions 3 et 5
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats sans hh:mm)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
def fetch_month_summaries(year: int, month: int, base_url: str = STATION_URL) -> dict[int, dict]:
"""Récupère tous les résumés journaliers d'un mois depuis le fichier NOAA WeeWX.
Retourne un dict {numéro_jour: data_dict} pour chaque jour disponible du mois.
Un seul appel HTTP par mois — utilisé pour le backfill groupé.
"""
try:
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year:04d}-{month:02d}.txt"
r = httpx.get(url, timeout=15)
r.raise_for_status()
result: dict[int, dict] = {}
for line in r.text.splitlines():
parts = line.split()
if not parts or not parts[0].isdigit():
continue
data = _parse_noaa_day_line(parts)
if data:
result[int(parts[0])] = data
return result
except Exception as e:
logger.warning(f"Station fetch_month_summaries({year}-{month:02d}) error: {e}")
return {}
def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
"""Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.
Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
"""
yesterday = (datetime.now() - timedelta(days=1)).date()
year = yesterday.strftime("%Y")
month = yesterday.strftime("%m")
day = yesterday.day
try:
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt"
r = httpx.get(url, timeout=15)
r.raise_for_status()
for line in r.text.splitlines():
parts = line.split()
if not parts or not parts[0].isdigit() or int(parts[0]) != day:
continue
# Format WeeWX NOAA (fréquent) :
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
except Exception as e:
logger.warning(f"Station fetch_yesterday_summary error: {e}")
return None
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
return month_data.get(yesterday.day)

View File

@@ -1,27 +1,47 @@
import os
from typing import List
from typing import List, Optional
import httpx
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070")
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070")
# Mapping class_name YOLO → nom commun français (partiel)
_NOMS_FR = {
"Tomato___healthy": "Tomate (saine)",
"Tomato___Early_blight": "Tomate (mildiou précoce)",
"Tomato___Late_blight": "Tomate (mildiou tardif)",
"Pepper__bell___healthy": "Poivron (sain)",
"Apple___healthy": "Pommier (sain)",
"Potato___healthy": "Pomme de terre (saine)",
"Grape___healthy": "Vigne (saine)",
"Corn_(maize)___healthy": "Maïs (sain)",
"Strawberry___healthy": "Fraisier (sain)",
"Peach___healthy": "Pêcher (sain)",
# Mapping complet class_name YOLO → Infos détaillées
_DIAGNOSTICS = {
"Tomato___healthy": {
"label": "Tomate (saine)",
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
"actions": ["Pailler le pied", "Vérifier les gourmands"]
},
"Tomato___Early_blight": {
"label": "Tomate (Alternariose)",
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
},
"Tomato___Late_blight": {
"label": "Tomate (Mildiou)",
"conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.",
"actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"]
},
"Pepper__bell___healthy": {
"label": "Poivron (sain)",
"conseil": "Le poivron aime la chaleur et un sol riche.",
"actions": ["Apport de compost", "Arrosage régulier"]
},
"Potato___healthy": {
"label": "Pomme de terre (saine)",
"conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.",
"actions": ["Butter les pieds"]
},
"Grape___healthy": {
"label": "Vigne (saine)",
"conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.",
"actions": ["Taille en vert", "Vérifier sous les feuilles"]
},
}
async def identify(image_bytes: bytes) -> List[dict]:
"""Appelle l'ai-service interne et retourne les détections YOLO."""
"""Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
results = []
for det in data[:3]:
cls = det.get("class_name", "")
diag = _DIAGNOSTICS.get(cls, {
"label": cls.replace("___", "").replace("_", " "),
"conseil": "Pas de diagnostic spécifique disponible pour cette espèce.",
"actions": []
})
results.append({
"species": cls.replace("___", "").replace("_", " "),
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
"species": cls,
"common_name": diag["label"],
"confidence": det.get("confidence", 0.0),
"conseil": diag["conseil"],
"actions": diag["actions"],
"image_url": "",
})
return results

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🌿 Jardin</title>
<meta name="theme-color" content="#282828" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon.svg" color="#282828" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
</head>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#282828"/>
<text y=".9em" font-size="70" x="15">🌿</text>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View File

@@ -9,6 +9,9 @@
<span>Disk {{ debugDiskLabel }}</span>
</div>
<!-- Notifications toast globales -->
<ToastNotification />
<!-- Mobile: header + drawer -->
<AppHeader class="lg:hidden" @toggle-drawer="drawerOpen = !drawerOpen" />
<AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
@@ -38,8 +41,12 @@
</aside>
<!-- Main content -->
<main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg" style="font-size: var(--ui-font-size, 14px)">
<RouterView />
<main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>
@@ -49,6 +56,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppDrawer from '@/components/AppDrawer.vue'
import ToastNotification from '@/components/ToastNotification.vue'
import { meteoApi } from '@/api/meteo'
import { settingsApi, type DebugSystemStats } from '@/api/settings'
import { applyUiSizesToRoot } from '@/utils/uiSizeDefaults'

View File

@@ -1,5 +1,37 @@
import axios from 'axios'
import axios, { type AxiosError } from 'axios'
import { useToast } from '@/composables/useToast'
export default axios.create({
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '',
})
client.interceptors.response.use(
response => response,
(error: AxiosError<{ detail?: string; message?: string }>) => {
const { error: showError } = useToast()
if (error.response) {
const status = error.response.status
const detail = error.response.data?.detail ?? error.response.data?.message
if (status === 422) {
const msg = Array.isArray(detail)
? (detail as Array<{ msg?: string }>).map(d => d.msg ?? d).join(', ')
: (detail ?? 'Vérifiez les champs du formulaire')
showError(`Données invalides : ${msg}`)
} else if (status === 404) {
showError('Ressource introuvable')
} else if (status >= 500) {
showError(`Erreur serveur (${status}) — réessayez dans un instant`)
} else if (status !== 401 && status !== 403) {
showError(String(detail ?? `Erreur ${status}`))
}
} else if (error.request) {
showError('Serveur inaccessible — vérifiez votre connexion réseau')
}
return Promise.reject(error)
},
)
export default client

View File

@@ -56,6 +56,10 @@ export const gardensApi = {
},
delete: (id: number) => client.delete(`/api/gardens/${id}`),
cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data),
createCell: (id: number, cell: Partial<GardenCell>) =>
client.post<GardenCell>(`/api/gardens/${id}/cells`, cell).then(r => r.data),
updateCell: (id: number, cellId: number, cell: Partial<GardenCell>) =>
client.put<GardenCell>(`/api/gardens/${id}/cells/${cellId}`, cell).then(r => r.data),
measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data),
addMeasurement: (id: number, m: Partial<Measurement>) =>
client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),

View File

@@ -0,0 +1,25 @@
import client from './client'
export interface DiagnosticResult {
species: string
common_name: string
confidence: number
conseil: string
actions: string[]
image_url?: string
}
export interface IdentifyResponse {
source: 'plantnet' | 'yolo' | 'cache'
results: DiagnosticResult[]
}
export const identifyApi = {
identify: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return client.post<IdentifyResponse>('/api/identify', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data)
}
}

View File

@@ -5,6 +5,7 @@ export interface Planting {
garden_id: number
variety_id: number
cell_id?: number
cell_ids?: number[] // multi-sélect zones
date_plantation?: string
quantite: number
statut: string

View File

@@ -0,0 +1,72 @@
<template>
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click.self="$emit('close')">
<div class="card-jardin w-full max-w-md overflow-hidden animate-fade-in border-green/30">
<div class="flex items-center justify-between mb-4 border-b border-bg-hard pb-3">
<h3 class="text-green font-bold flex items-center gap-2">
<span>🔬</span> Diagnostic IA
</h3>
<button @click="$emit('close')" class="text-text-muted hover:text-red transition-colors"></button>
</div>
<div v-if="loading" class="py-12 flex flex-col items-center gap-4">
<div class="w-12 h-12 border-4 border-green/20 border-t-green rounded-full animate-spin"></div>
<p class="text-text-muted text-sm animate-pulse italic">Analyse de l'image en cours...</p>
</div>
<div v-else-if="result" class="space-y-6">
<!-- En-tête du résultat -->
<div class="flex items-center gap-4 bg-bg-hard p-3 rounded-xl">
<div class="text-3xl">🌿</div>
<div>
<div class="text-text font-bold text-lg">{{ result.common_name }}</div>
<div class="text-[10px] text-text-muted uppercase tracking-widest font-bold">Confiance : {{ (result.confidence * 100).toFixed(1) }}%</div>
</div>
</div>
<!-- Conseil -->
<div class="space-y-2">
<div class="text-[10px] font-bold text-green uppercase tracking-widest">Conseil de l'expert</div>
<p class="text-sm text-text-muted leading-relaxed bg-green/5 p-3 rounded-lg border border-green/10">
{{ result.conseil }}
</p>
</div>
<!-- Actions suggérées -->
<div v-if="result.actions?.length" class="space-y-2">
<div class="text-[10px] font-bold text-orange uppercase tracking-widest">Actions recommandées</div>
<div class="flex flex-wrap gap-2">
<span v-for="action in result.actions" :key="action" class="badge badge-orange py-1.5 px-3">
{{ action }}
</span>
</div>
</div>
<button @click="$emit('close')" class="btn-primary w-full mt-4">Compris, merci !</button>
</div>
<div v-else class="py-8 text-center text-text-muted">
Impossible d'analyser l'image. Réessayez avec une photo plus nette.
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
loading: boolean
result: any | null
}>()
defineEmits(['close'])
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<Teleport to="body">
<div class="fixed bottom-4 right-4 z-[200] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto flex items-start gap-3 rounded-xl px-4 py-3 shadow-2xl border text-sm font-medium cursor-pointer"
:class="toastClass(toast.type)"
@click="remove(toast.id)"
>
<span class="text-base leading-none mt-0.5 shrink-0">{{ toastIcon(toast.type) }}</span>
<span class="flex-1 leading-snug">{{ toast.message }}</span>
<button
class="ml-1 opacity-50 hover:opacity-100 transition-opacity shrink-0 text-xs"
@click.stop="remove(toast.id)"
></button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
const { toasts, remove } = useToast()
function toastClass(type: string): string {
const map: Record<string, string> = {
success: 'bg-[#3c3836] border-[#b8bb26]/50 text-[#b8bb26]',
error: 'bg-[#3c3836] border-[#fb4934]/50 text-[#fb4934]',
warning: 'bg-[#3c3836] border-[#fabd2f]/50 text-[#fabd2f]',
info: 'bg-[#3c3836] border-[#83a598]/50 text-[#83a598]',
}
return map[type] ?? 'bg-[#3c3836] border-[#a89984]/40 text-[#ebdbb2]'
}
function toastIcon(type: string): string {
const map: Record<string, string> = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
}
return map[type] ?? ''
}
</script>
<style scoped>
.toast-enter-active { transition: all 0.25s ease-out; }
.toast-leave-active { transition: all 0.2s ease-in; }
.toast-enter-from { opacity: 0; transform: translateX(30px); }
.toast-leave-to { opacity: 0; transform: translateX(30px); }
.toast-move { transition: transform 0.2s ease; }
</style>

View File

@@ -0,0 +1,38 @@
import { reactive } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: number
type: ToastType
message: string
duration: number
}
const toasts = reactive<Toast[]>([])
let nextId = 1
function add(message: string, type: ToastType = 'info', duration = 4000): number {
const id = nextId++
toasts.push({ id, type, message, duration })
if (duration > 0) {
setTimeout(() => remove(id), duration)
}
return id
}
function remove(id: number) {
const index = toasts.findIndex(t => t.id === id)
if (index !== -1) toasts.splice(index, 1)
}
export function useToast() {
return {
toasts,
success: (msg: string, duration?: number) => add(msg, 'success', duration ?? 4000),
error: (msg: string, duration?: number) => add(msg, 'error', duration ?? 6000),
warning: (msg: string, duration?: number) => add(msg, 'warning', duration ?? 5000),
info: (msg: string, duration?: number) => add(msg, 'info', duration ?? 4000),
remove,
}
}

View File

@@ -3,5 +3,8 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
import { registerSW } from 'virtual:pwa-register'
registerSW({ immediate: true })
createApp(App).use(createPinia()).use(router).mount('#app')

View File

@@ -2,13 +2,56 @@
@tailwind components;
@tailwind utilities;
body {
@apply bg-bg text-text font-mono;
min-height: 100vh;
@layer base {
html {
font-size: var(--ui-font-size, 14px);
}
body {
@apply bg-bg text-text font-mono selection:bg-yellow/30;
min-height: 100vh;
}
}
@layer components {
/* Cartes avec effet 70s */
.card-jardin {
@apply bg-bg-soft border border-bg-hard rounded-xl p-4 shadow-sm
transition-all duration-300 hover:shadow-lg hover:border-text-muted/30;
}
/* Boutons stylisés */
.btn-primary {
@apply bg-yellow text-bg px-4 py-2 rounded-lg font-bold text-sm
transition-transform active:scale-95 hover:opacity-90;
}
.btn-outline {
@apply border border-bg-hard text-text-muted px-4 py-2 rounded-lg text-sm
hover:bg-bg-hard hover:text-text transition-all;
}
/* Badges colorés */
.badge {
@apply px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider;
}
.badge-green { @apply bg-green/20 text-green; }
.badge-yellow { @apply bg-yellow/20 text-yellow; }
.badge-blue { @apply bg-blue/20 text-blue; }
.badge-red { @apply bg-red/20 text-red; }
.badge-orange { @apply bg-orange/20 text-orange; }
}
/* Transitions de pages */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from { opacity: 0; transform: translateY(5px); }
.fade-leave-to { opacity: 0; transform: translateY(-5px); }
* { box-sizing: border-box; }
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #1d2021; }
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #665c54; }

View File

@@ -3,6 +3,8 @@ export const UI_SIZE_DEFAULTS: Record<string, number> = {
ui_menu_font_size: 13,
ui_menu_icon_size: 18,
ui_thumb_size: 96,
ui_weather_icon_size: 48,
ui_dashboard_icon_size: 24,
}
export function applyUiSizesToRoot(data: Record<string, string | number>): void {

View File

@@ -1,204 +1,200 @@
<template>
<div class="p-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-yellow">💡 Astuces</h1>
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
+ Ajouter
<div class="p-4 max-w-[1800px] mx-auto space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-yellow tracking-tight">Astuces & Conseils</h1>
<p class="text-text-muted text-xs mt-1">Bibliothèque de savoir-faire pour votre potager.</p>
</div>
<button @click="openCreate" class="btn-primary !bg-yellow !text-bg flex items-center gap-2 shadow-lg hover:shadow-yellow/20 transition-all">
<span class="text-lg leading-none">+</span> Ajouter
</button>
</div>
<div class="flex flex-wrap items-center gap-2 mb-4">
<!-- Filtres optimisés -->
<div class="flex flex-wrap items-center gap-3 bg-bg-soft/30 p-2 rounded-2xl border border-bg-soft shadow-inner text-xs">
<select
v-model="filterCategorie"
class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow"
class="bg-bg border border-bg-soft rounded-xl px-4 py-2 text-text text-xs outline-none focus:border-yellow transition-all appearance-none cursor-pointer"
>
<option value="">Toutes catégories</option>
<option value="plante">Plante</option>
<option value="jardin">Jardin</option>
<option value="tache">Tâche</option>
<option value="general">Général</option>
<option value="ravageur">Ravageur</option>
<option value="maladie">Maladie</option>
<option value="plante">🌱 Plante</option>
<option value="jardin">🏡 Jardin</option>
<option value="tache"> Tâche</option>
<option value="general">📖 Général</option>
<option value="ravageur">🐛 Ravageur</option>
<option value="maladie">🍄 Maladie</option>
</select>
<input
v-model="filterTag"
placeholder="Filtrer par tag..."
class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow w-44"
/>
<div class="relative flex-1 min-w-[200px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
<input
v-model="filterTag"
placeholder="Filtrer par mot-clé..."
class="w-full bg-bg border border-bg-soft rounded-xl pl-9 pr-4 py-2 text-text text-xs outline-none focus:border-yellow transition-all"
/>
</div>
<button
@click="filterMoisActuel = !filterMoisActuel"
:class="[
'px-3 py-1 rounded-full text-xs font-medium transition-colors border',
filterMoisActuel ? 'bg-green/20 text-green border-green/40' : 'border-bg-hard text-text-muted',
'px-4 py-2 rounded-xl text-xs font-bold transition-all border uppercase tracking-widest',
filterMoisActuel ? 'bg-green/20 text-green border-green/40 shadow-lg' : 'border-bg-soft text-text-muted hover:text-text',
]"
>
📅 Ce mois
</button>
<button @click="refresh" class="text-xs text-text-muted hover:text-text underline ml-auto">Rafraîchir</button>
<button @click="refresh" class="p-2 text-text-muted hover:text-yellow transition-colors" title="Actualiser">
🔄
</button>
</div>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!store.astuces.length" class="text-text-muted text-sm py-6">Aucune astuce pour ce filtre.</div>
<div v-if="store.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-4">
<div v-for="i in 6" :key="i" class="card-jardin h-48 animate-pulse opacity-20"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-else-if="!store.astuces.length" class="card-jardin text-center py-16 opacity-50 border-dashed border-2">
<div class="text-4xl mb-4">💡</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucun conseil trouvé</p>
</div>
<!-- Grille multi-colonnes -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<div
v-for="a in store.astuces"
:key="a.id"
class="bg-bg-soft rounded-xl p-4 border border-bg-hard"
class="card-jardin group flex flex-col h-full hover:border-yellow/40 transition-all shadow-sm hover:shadow-xl !p-4"
>
<div class="flex items-start justify-between gap-2 mb-2">
<h2 class="text-text font-semibold leading-tight">{{ a.titre }}</h2>
<div class="flex gap-2 shrink-0">
<button @click="openEdit(a)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click="removeAstuce(a.id)" class="text-red text-xs hover:underline">Suppr.</button>
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors leading-tight truncate" :title="a.titre">{{ a.titre }}</h2>
<div class="flex items-center gap-2 mt-1.5">
<span v-if="a.categorie" class="badge badge-yellow !text-[8px] !px-1.5 !py-0.5">{{ a.categorie }}</span>
<span v-if="parseMois(a.mois).length" class="text-[8px] font-bold text-green uppercase tracking-widest bg-green/5 px-1.5 py-0.5 rounded border border-green/10">
📅 {{ parseMois(a.mois).join(', ') }}
</span>
</div>
</div>
<div class="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="openEdit(a)" class="p-1 text-text-muted hover:text-yellow transition-transform hover:scale-110"></button>
<button @click="removeAstuce(a.id)" class="p-1 text-text-muted hover:text-red transition-transform hover:scale-110"></button>
</div>
</div>
<p class="text-text-muted text-sm whitespace-pre-line">{{ a.contenu }}</p>
<p class="text-text-muted text-xs leading-relaxed whitespace-pre-line mb-4 flex-1 line-clamp-4 group-hover:line-clamp-none transition-all">
{{ a.contenu }}
</p>
<div v-if="parseMediaUrls(a.photos).length" class="mt-3 grid grid-cols-3 gap-2">
<img
v-for="(url, idx) in parseMediaUrls(a.photos)"
:key="`astuce-photo-${a.id}-${idx}`"
:src="url"
alt="photo astuce"
class="w-full h-20 object-cover rounded-md border border-bg-hard"
/>
<!-- Médias compacts -->
<div v-if="parseMediaUrls(a.photos).length" class="mb-4 grid grid-cols-3 gap-1.5">
<div
v-for="(url, idx) in parseMediaUrls(a.photos).slice(0, 3)"
:key="idx"
class="relative aspect-square rounded-lg overflow-hidden border border-bg-hard shadow-sm"
>
<img :src="url" class="w-full h-full object-cover hover:scale-110 transition-transform cursor-pointer" />
<div v-if="idx === 2 && parseMediaUrls(a.photos).length > 3" class="absolute inset-0 bg-black/60 flex items-center justify-center text-[10px] font-black text-white pointer-events-none">
+{{ parseMediaUrls(a.photos).length - 3 }}
</div>
</div>
</div>
<div v-if="parseMediaUrls(a.videos).length" class="mt-3 space-y-2">
<video
v-for="(url, idx) in parseMediaUrls(a.videos)"
:key="`astuce-video-${a.id}-${idx}`"
:src="url"
controls
muted
class="w-full rounded-md border border-bg-hard bg-black/40 max-h-52"
/>
<div v-if="parseMediaUrls(a.videos).length" class="mb-4">
<div class="relative aspect-video rounded-xl overflow-hidden border border-bg-hard bg-black/20 group/vid">
<video :src="parseMediaUrls(a.videos)[0]" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-100 group-hover/vid:bg-black/20 transition-all">
<span class="text-2xl">🎬</span>
</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-1">
<span v-if="a.categorie" class="text-[11px] bg-yellow/15 text-yellow rounded-full px-2 py-0.5">{{ a.categorie }}</span>
<span v-for="t in parseTags(a.tags)" :key="`${a.id}-t-${t}`" class="text-[11px] bg-blue/15 text-blue rounded-full px-2 py-0.5">#{{ t }}</span>
<span v-if="parseMois(a.mois).length" class="text-[11px] bg-green/15 text-green rounded-full px-2 py-0.5">mois: {{ parseMois(a.mois).join(',') }}</span>
<!-- Tags -->
<div class="flex flex-wrap items-center gap-1.5 pt-3 border-t border-bg-hard/50">
<span v-for="t in parseTags(a.tags)" :key="t" class="text-[9px] font-black uppercase tracking-tighter text-blue bg-blue/5 px-1.5 py-0.5 rounded border border-blue/10">
#{{ t }}
</span>
</div>
</div>
</div>
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-lg border border-bg-soft">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier astuce' : 'Nouvelle astuce' }}</h2>
<form @submit.prevent="submitAstuce" class="flex flex-col gap-3">
<input
v-model="form.titre"
placeholder="Titre *"
required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
<!-- Modal Formulaire -->
<div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-bold text-xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'astuce' : 'Nouvelle pépite' }}</h2>
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl"></button>
</div>
<textarea
v-model="form.contenu"
placeholder="Contenu *"
required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-28"
/>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<select
v-model="form.categorie"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
>
<option value="">Catégorie</option>
<option value="plante">Plante</option>
<option value="jardin">Jardin</option>
<option value="tache">Tâche</option>
<option value="general">Général</option>
<option value="ravageur">Ravageur</option>
<option value="maladie">Maladie</option>
</select>
<input
v-model="form.source"
placeholder="Source"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
<form @submit.prevent="submitAstuce" class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Titre accrocheur *</label>
<input v-model="form.titre" required placeholder="Ex: Mieux gérer le mildiou..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none transition-all shadow-inner" />
</div>
<input
v-model="form.tagsInput"
placeholder="Tags (ex: tomate, semis, mildiou)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Corps du texte *</label>
<textarea v-model="form.contenu" required rows="1" placeholder="Partagez votre savoir-faire..."
@input="autoResize"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none transition-all shadow-inner overflow-hidden" />
</div>
<input
v-model="form.moisInput"
placeholder="Mois (ex: 3,4,5)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow">
<option value=""> Choisir </option>
<option value="plante">🌱 Plante</option>
<option value="jardin">🏡 Jardin</option>
<option value="tache"> Tâche</option>
<option value="general">📖 Général</option>
<option value="ravageur">🐛 Ravageur</option>
<option value="maladie">🍄 Maladie</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Source (URL/Nom)</label>
<input v-model="form.source" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm cursor-pointer text-center hover:border-yellow">
{{ uploadingPhotos ? 'Upload photos...' : 'Ajouter photo(s)' }}
<input
type="file"
accept="image/*"
multiple
class="hidden"
@change="uploadFiles($event, 'photo')"
/>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags (virgule)</label>
<input v-model="form.tagsInput" placeholder="tomate, mildiou..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois (virgule)</label>
<input v-model="form.moisInput" placeholder="3, 4, 5..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 pt-2">
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-yellow hover:text-yellow text-[10px] font-black uppercase transition-all">
{{ uploadingPhotos ? '...' : '📸 Photos' }}
<input type="file" accept="image/*" multiple class="hidden" @change="uploadFiles($event, 'photo')" />
</label>
<label class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm cursor-pointer text-center hover:border-yellow">
{{ uploadingVideos ? 'Upload vidéos...' : 'Ajouter vidéo(s)' }}
<input
type="file"
accept="video/*"
multiple
class="hidden"
@change="uploadFiles($event, 'video')"
/>
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-aqua hover:text-aqua text-[10px] font-black uppercase transition-all">
{{ uploadingVideos ? '...' : '🎬 Vidéos' }}
<input type="file" accept="video/*" multiple class="hidden" @change="uploadFiles($event, 'video')" />
</label>
</div>
<div v-if="form.photos.length" class="bg-bg border border-bg-soft rounded-lg p-2">
<div class="text-xs text-text-muted mb-1">Photos jointes</div>
<div class="grid grid-cols-3 gap-2">
<div v-for="(url, idx) in form.photos" :key="`form-photo-${idx}`" class="relative group">
<img :src="url" alt="photo astuce" class="w-full h-16 object-cover rounded border border-bg-hard" />
<button
type="button"
class="absolute top-1 right-1 hidden group-hover:block bg-red/80 text-white text-[10px] rounded px-1"
@click="removeMedia('photo', idx)"
>
</button>
<div v-if="form.photos.length || form.videos.length" class="bg-bg-soft/30 rounded-2xl p-3 border border-bg-soft max-h-40 overflow-y-auto space-y-3 shadow-inner">
<div v-if="form.photos.length" class="grid grid-cols-6 gap-2">
<div v-for="(url, idx) in form.photos" :key="idx" class="relative group aspect-square">
<img :src="url" class="w-full h-full object-cover rounded-lg border border-bg-hard" />
<button type="button" @click="removeMedia('photo', idx)" class="absolute top-1 right-1 bg-red text-white text-[8px] w-4 h-4 rounded-full flex items-center justify-center shadow-lg"></button>
</div>
</div>
</div>
<div v-if="form.videos.length" class="bg-bg border border-bg-soft rounded-lg p-2">
<div class="text-xs text-text-muted mb-1">Vidéos jointes</div>
<div class="space-y-2">
<div v-for="(url, idx) in form.videos" :key="`form-video-${idx}`" class="relative group">
<video :src="url" controls muted class="w-full max-h-36 rounded border border-bg-hard bg-black/40" />
<button
type="button"
class="absolute top-1 right-1 hidden group-hover:block bg-red/80 text-white text-[10px] rounded px-1"
@click="removeMedia('video', idx)"
>
</button>
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-1">
<button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editId ? 'Enregistrer' : 'Créer' }}
<div class="flex justify-between items-center pt-6 border-t border-bg-soft mt-2">
<button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6" @click="closeForm">Annuler</button>
<button type="submit" class="btn-primary !bg-yellow !text-bg px-12 py-3 shadow-xl">
{{ editId ? 'Sauvegarder' : 'Partager' }}
</button>
</div>
</form>
@@ -371,6 +367,12 @@ function closeForm() {
showForm.value = false
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
async function submitAstuce() {
const payload = {
titre: form.titre.trim(),

View File

@@ -1,109 +1,120 @@
<template>
<div class="p-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">📷 Bibliothèque</h1>
<button @click="showIdentify = true"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
Identifier une plante
<div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Bibliothèque Photo</h1>
<p class="text-text-muted text-xs mt-1">Gérez vos captures et identifiez vos plantes par IA.</p>
</div>
<button @click="showIdentify = true" class="btn-primary flex items-center gap-2">
<span>🔬</span> Identifier
</button>
</div>
<!-- Filtres -->
<div class="flex gap-2 mb-4 flex-wrap">
<div class="flex gap-2 mb-4 bg-bg-soft/30 p-1 rounded-full w-fit border border-bg-soft overflow-x-auto max-w-full no-scrollbar">
<button v-for="f in filters" :key="f.val" @click="activeFilter = f.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
activeFilter === f.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
:class="['px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest transition-all whitespace-nowrap',
activeFilter === f.val ? 'bg-green text-bg shadow-lg' : 'text-text-muted hover:text-text']">
{{ f.label }}
</button>
</div>
<div v-if="loading" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-for="i in 5" :key="i" class="aspect-square card-jardin animate-pulse opacity-20"></div>
</div>
<div v-else-if="!filtered.length" class="card-jardin text-center py-16 opacity-50 border-dashed">
<div class="text-4xl mb-4">📸</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucune photo trouvée</p>
</div>
<!-- Grille -->
<div v-if="loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!filtered.length" class="text-text-muted text-sm py-4">Aucune photo.</div>
<div v-else class="grid grid-cols-3 md:grid-cols-4 gap-2">
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-for="m in filtered" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg-hard relative group cursor-pointer"
class="group relative aspect-square rounded-2xl overflow-hidden bg-bg-hard border border-bg-soft/50 cursor-pointer shadow-sm hover:shadow-2xl hover:scale-[1.02] transition-all"
@click="openLightbox(m)">
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" class="w-full h-full object-cover" />
<div v-if="m.identified_common"
class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">
{{ m.identified_common }}
</div>
<div class="absolute top-1 left-1 bg-black/60 text-text-muted text-xs px-1 rounded">
{{ labelFor(m.entity_type) }}
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<!-- Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="absolute bottom-2 left-2 right-2 pointer-events-none">
<div v-if="m.identified_common" class="text-[10px] text-green font-black uppercase tracking-tighter truncate drop-shadow-md">
{{ m.identified_common }}
</div>
<div class="text-[8px] text-text-muted font-bold uppercase tracking-widest opacity-80">
{{ labelFor(m.entity_type) }}
</div>
</div>
<button @click.stop="deleteMedia(m)"
class="hidden group-hover:flex absolute top-1 right-1 bg-red/80 text-white text-xs rounded px-1"></button>
class="absolute top-2 right-2 bg-red/80 hover:bg-red text-white p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all scale-75 group-hover:scale-100">
<span class="text-xs"></span>
</button>
</div>
</div>
<!-- Lightbox -->
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4" @click.self="lightbox = null">
<div class="max-w-lg w-full bg-bg-hard rounded-xl overflow-hidden border border-bg-soft">
<img :src="lightbox.url" class="w-full" />
<div class="p-4">
<!-- Infos identification -->
<div v-if="lightbox.identified_species" class="text-center mb-3">
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
<!-- Lightbox stylisée -->
<div v-if="lightbox" class="fixed inset-0 bg-black/95 backdrop-blur-md z-[150] flex items-center justify-center p-4" @click.self="lightbox = null">
<div class="max-w-2xl w-full bg-bg-hard rounded-3xl overflow-hidden border border-bg-soft shadow-2xl animate-fade-in">
<div class="relative aspect-video sm:aspect-square bg-bg overflow-hidden">
<img :src="lightbox.url" class="w-full h-full object-contain" />
<button @click="lightbox = null" class="absolute top-4 right-4 bg-black/50 text-white w-10 h-10 rounded-full flex items-center justify-center hover:bg-red transition-colors"></button>
</div>
<div class="p-6 space-y-6">
<div v-if="lightbox.identified_species" class="text-center space-y-1">
<h2 class="text-green font-black text-2xl uppercase tracking-tighter">{{ lightbox.identified_common }}</h2>
<div class="italic text-text-muted text-sm">{{ lightbox.identified_species }}</div>
<div class="text-xs text-text-muted mt-1">
Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% via {{ lightbox.identified_source }}
<div class="badge badge-green !text-[9px] mt-2">
Confiance {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% · via {{ lightbox.identified_source }}
</div>
</div>
<!-- Lien actuel -->
<div class="text-xs text-text-muted mb-3 text-center">
{{ labelFor(lightbox.entity_type) }}
<span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)">
: <span class="text-green font-medium">{{ plantName(lightbox.entity_id) }}</span>
<div class="flex items-center justify-center gap-2 text-xs text-text-muted font-bold uppercase tracking-widest border-y border-bg-soft py-3">
<span>Type :</span>
<span class="text-text">{{ labelFor(lightbox.entity_type) }}</span>
<span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)" class="text-green">
({{ plantName(lightbox.entity_id) }})
</span>
</div>
<!-- Actions -->
<div class="flex gap-2 flex-wrap">
<button @click="startLink(lightbox!)"
class="flex-1 bg-blue/20 text-blue hover:bg-blue/30 px-3 py-2 rounded-lg text-xs font-medium transition-colors">
🔗 Associer à une plante
<div class="flex gap-3">
<button @click="startLink(lightbox!)" class="btn-outline flex-1 py-3 border-blue/20 text-blue hover:bg-blue/10 text-[10px] font-black uppercase tracking-widest">
🔗 Associer
</button>
<button
@click="toggleAdventice(lightbox!)"
:class="[
'px-3 py-2 rounded-lg text-xs font-medium transition-colors',
isAdventice(lightbox!) ? 'bg-red/20 text-red hover:bg-red/30' : 'bg-green/20 text-green hover:bg-green/30'
]"
>
{{ isAdventice(lightbox!) ? '🪓 Retirer adventice' : '🌾 Marquer adventice' }}
<button @click="toggleAdventice(lightbox!)"
:class="['btn-outline flex-1 py-3 text-[10px] font-black uppercase tracking-widest',
isAdventice(lightbox!) ? 'border-red/20 text-red hover:bg-red/10' : 'border-green/20 text-green hover:bg-green/10']">
{{ isAdventice(lightbox!) ? '🪓 Pas Adventice' : '🌾 Adventice' }}
</button>
<button @click="deleteMedia(lightbox!); lightbox = null"
class="bg-red/20 text-red hover:bg-red/30 px-3 py-2 rounded-lg text-xs font-medium transition-colors">
🗑 Supprimer
<button @click="deleteMedia(lightbox!); lightbox = null" class="btn-outline py-3 px-4 border-red/20 text-red hover:bg-red/10">
🗑
</button>
</div>
<button class="mt-3 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
</div>
</div>
</div>
<!-- Modal associer à une plante -->
<div v-if="linkMedia" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="linkMedia = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft">
<h3 class="text-text font-bold mb-4">Associer à une plante</h3>
<!-- Modal associer -->
<div v-if="linkMedia" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[200] flex items-center justify-center p-4" @click.self="linkMedia = null">
<div class="bg-bg-hard rounded-2xl p-6 w-full max-w-sm border border-bg-soft shadow-2xl">
<h3 class="text-text font-black uppercase tracking-tighter text-lg mb-4">Lier à une plante</h3>
<select v-model="linkPlantId"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green mb-4">
<option :value="null">-- Choisir une plante --</option>
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-green mb-6 appearance-none shadow-inner">
<option :value="null">-- Choisir dans la liste --</option>
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
{{ formatPlantLabel(p) }}
</option>
</select>
<div class="flex gap-2 justify-end">
<button @click="linkMedia = null" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button @click="confirmLink" :disabled="!linkPlantId"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
Associer
</button>
<div class="flex gap-3 justify-end">
<button @click="linkMedia = null" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-4">Annuler</button>
<button @click="confirmLink" :disabled="!linkPlantId" class="btn-primary px-6 disabled:opacity-30">Confirmer</button>
</div>
</div>
</div>
<!-- Modal identification -->
<PhotoIdentifyModal v-if="showIdentify" @close="showIdentify = false" @identified="onIdentified" />
</div>
</template>

View File

@@ -1,204 +1,229 @@
<template>
<div class="p-4 max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-blue mb-4">🌦 Météo</h1>
<div class="p-4 max-w-7xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-blue tracking-tight">Météo & Calendrier</h1>
<p class="text-text-muted text-xs mt-1">Données locales, prévisions et cycles lunaires combinés.</p>
</div>
<div class="flex flex-wrap items-center gap-2 mb-4">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium bg-bg-soft text-text hover:text-blue border border-bg-hard"
@click="shiftWindow(-spanDays)"
>
Prev
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium bg-blue/20 text-blue border border-blue/30"
@click="goToday"
>
Today
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium bg-bg-soft text-text hover:text-blue border border-bg-hard"
@click="shiftWindow(spanDays)"
>
Next
</button>
<span class="text-text-muted text-xs ml-1">
Fenêtre: {{ formatDate(rangeStart) }} {{ formatDate(rangeEnd) }}
</span>
<!-- Navigateur -->
<div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
<button @click="shiftWindow(-spanDays)" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Préc.</button>
<button @click="goToday" class="btn-primary !py-1.5 !px-4 text-xs !rounded-lg">Aujourd'hui</button>
<button @click="shiftWindow(spanDays)" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Suiv.</button>
</div>
</div>
<div
v-if="stationCurrent"
class="bg-bg-soft rounded-xl p-4 border border-bg-hard mb-4 flex flex-wrap gap-4 items-center"
>
<div>
<div class="text-text-muted text-xs mb-1">Température extérieure</div>
<div class="text-text text-2xl font-bold">{{ stationCurrent.temp_ext?.toFixed(1) ?? '—' }}°C</div>
</div>
<div class="flex gap-4 text-sm">
<span v-if="stationCurrent.humidite != null" class="text-blue">💧{{ stationCurrent.humidite }}%</span>
<span v-if="stationCurrent.vent_kmh != null" class="text-text">💨{{ stationCurrent.vent_kmh }} km/h {{ stationCurrent.vent_dir || '' }}</span>
<span v-if="stationCurrent.pression != null" class="text-text">🧭{{ stationCurrent.pression }} hPa</span>
</div>
<div class="flex items-center gap-2">
<img
v-if="currentOpenMeteo?.wmo != null"
:src="weatherIcon(currentOpenMeteo.wmo)"
class="w-6 h-6"
:alt="currentOpenMeteo.label || 'Météo'"
/>
<div>
<div class="text-text-muted text-xs mb-1">Condition actuelle</div>
<div class="text-text text-sm">{{ currentOpenMeteo?.label || '—' }}</div>
<!-- Widgets Station & Actuel -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="md:col-span-2 card-jardin border-blue/20 bg-gradient-to-br from-bg-soft to-bg-hard flex flex-wrap items-center justify-around gap-6 py-6">
<div class="text-center">
<div class="text-text-muted text-[10px] uppercase font-black tracking-widest mb-1">Température Ext.</div>
<div class="text-4xl font-bold text-text">{{ stationCurrent?.temp_ext?.toFixed(1) ?? '--' }}°<span class="text-blue text-xl">C</span></div>
<div v-if="stationCurrent?.date_heure" class="text-[9px] text-text-muted font-bold uppercase mt-2 opacity-50">Relevé {{ stationCurrent.date_heure.slice(11, 16) }}</div>
</div>
<div class="h-12 w-px bg-bg-hard hidden sm:block"></div>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<span class="w-8 text-center text-blue text-xl">💧</span>
<div>
<div class="text-[9px] text-text-muted uppercase font-bold tracking-tighter leading-none">Humidité</div>
<div class="text-sm font-bold text-text">{{ stationCurrent?.humidite ?? '--' }}%</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="w-8 text-center text-orange text-xl">💨</span>
<div>
<div class="text-[9px] text-text-muted uppercase font-bold tracking-tighter leading-none">Vent</div>
<div class="text-sm font-bold text-text">{{ stationCurrent?.vent_kmh ?? '--' }} <span class="text-[10px]">km/h</span></div>
</div>
</div>
</div>
<div class="h-12 w-px bg-bg-hard hidden sm:block"></div>
<div class="flex items-center gap-4">
<img v-if="currentOpenMeteo?.wmo != null" :src="weatherIcon(currentOpenMeteo.wmo)" class="w-16 h-16 drop-shadow-xl" />
<div>
<div class="text-[9px] text-text-muted uppercase font-bold tracking-widest mb-1">État du ciel</div>
<div class="text-sm font-bold text-blue uppercase">{{ currentOpenMeteo?.label || '' }}</div>
<div class="text-[10px] text-text-muted font-mono mt-1">{{ stationCurrent?.pression ?? '--' }} hPa</div>
</div>
</div>
</div>
<div v-if="stationCurrent.date_heure" class="text-text-muted text-xs ml-auto">
Relevé {{ stationCurrent.date_heure.slice(11, 16) }}
<div class="card-jardin border-yellow/20 flex flex-col justify-center items-center text-center py-6">
<div class="text-text-muted text-[10px] uppercase font-black tracking-widest mb-2">Lune du jour</div>
<div class="text-5xl mb-3">
{{ lunarIcon(lunarForDate(todayIso())?.illumination ?? 0) }}
</div>
<div class="badge badge-yellow !text-[10px] mb-1">{{ lunarForDate(todayIso())?.croissante_decroissante || '--' }}</div>
<div class="text-xs font-bold text-text italic">Jour {{ lunarForDate(todayIso())?.type_jour || '--' }}</div>
</div>
</div>
<div v-if="loadingTableau" class="text-text-muted text-sm py-4">Chargement météo...</div>
<div v-else-if="!tableauRows.length" class="text-text-muted text-sm py-4">Pas de données météo.</div>
<!-- Tableau Synthétique -->
<div class="grid grid-cols-1 xl:grid-cols-4 gap-8">
<div class="xl:col-span-3 space-y-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Tableau de bord journalier
</h2>
<div v-else class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
<div class="overflow-x-auto bg-bg-soft rounded-xl border border-bg-hard p-2">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="text-text-muted text-xs">
<th class="text-left py-2 px-2">Date</th>
<th class="text-center py-2 px-2 text-blue" colspan="3">📡 Station locale</th>
<th class="text-center py-2 px-2 text-green border-l-2 border-bg-hard" colspan="4">🌐 Open-Meteo</th>
<th class="text-center py-2 px-2 text-yellow border-l-2 border-bg-hard" colspan="3">🌙 Lunaire</th>
</tr>
<tr class="text-text-muted text-xs border-b border-bg-hard">
<th class="text-left py-1 px-2"></th>
<th class="text-right py-1 px-1">T°min</th>
<th class="text-right py-1 px-1">T°max</th>
<th class="text-right py-1 px-1">💧mm</th>
<div class="card-jardin !p-0 overflow-hidden border-bg-hard/50 shadow-2xl">
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-bg-hard/50 text-[10px] font-black uppercase tracking-widest text-text-muted/60 border-b border-bg-hard">
<th class="py-4 px-4 text-left">Date</th>
<th class="py-4 px-2 text-center text-blue" colspan="3">Station</th>
<th class="py-4 px-2 text-center text-green" colspan="4">Prévisions</th>
<th class="py-4 px-4 text-right text-yellow">Lune</th>
</tr>
<tr class="text-[9px] font-bold uppercase tracking-tighter text-text-muted border-b border-bg-hard">
<th class="py-2 px-4"></th>
<th class="px-1 text-right opacity-60">Min</th>
<th class="px-1 text-right opacity-60">Max</th>
<th class="px-1 text-right opacity-60">Pluie</th>
<th class="px-1 text-right opacity-60 border-l border-bg-hard/30">Min</th>
<th class="px-1 text-right opacity-60">Max</th>
<th class="px-1 text-right opacity-60">Pluie</th>
<th class="px-2 text-left opacity-60">Ciel</th>
<th class="px-4 text-right opacity-60">Calendrier</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableauRows" :key="row.date"
@click="selectMeteoDate(row.date)"
:class="['group cursor-pointer border-b border-bg-hard/30 transition-all hover:bg-bg-soft/20',
row.type === 'aujourd_hui' ? 'bg-blue/5 !border-blue/30' : '',
selectedMeteoDate === row.date ? 'bg-blue/10 !border-blue' : '']">
<th class="text-right py-1 px-1 border-l-2 border-bg-hard">T°min</th>
<th class="text-right py-1 px-1">T°max</th>
<th class="text-right py-1 px-1">💧mm</th>
<th class="text-left py-1 px-2">État</th>
<td class="py-3 px-4">
<div :class="['text-sm font-black font-mono', row.type === 'aujourd_hui' ? 'text-blue' : 'text-text']">
{{ formatDate(row.date) }}
</div>
<div v-if="row.type === 'aujourd_hui'" class="text-[8px] uppercase tracking-tighter text-blue font-black mt-0.5">Aujourd'hui</div>
</td>
<th class="text-left py-1 px-2 border-l-2 border-bg-hard">Type lune</th>
<th class="text-left py-1 px-2">Mont./Desc.</th>
<th class="text-left py-1 px-2">Type jour</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in tableauRows"
:key="row.date"
@click="selectMeteoDate(row.date)"
:class="[
'border-b border-bg-hard transition-colors cursor-pointer',
row.type === 'passe' ? 'opacity-80' : '',
row.date === selectedMeteoDate ? 'bg-blue/10 border-blue' : '',
]"
>
<td class="py-2 px-2 text-text-muted text-xs whitespace-nowrap">
<span :class="row.type === 'aujourd_hui' ? 'text-green font-bold' : ''">
{{ formatDate(row.date) }}
</span>
</td>
<td class="text-right px-1 font-mono text-xs text-blue">{{ stationTMin(row) }}</td>
<td class="text-right px-1 font-mono text-xs text-orange font-bold">{{ stationTMax(row) }}</td>
<td class="text-right px-1 font-mono text-xs text-aqua">{{ stationRain(row) }}</td>
<td class="text-right px-1 text-blue text-xs">{{ stationTMin(row) }}</td>
<td class="text-right px-1 text-orange text-xs">{{ stationTMax(row) }}</td>
<td class="text-right px-1 text-blue text-xs">{{ stationRain(row) }}</td>
<td class="text-right px-1 font-mono text-xs text-blue border-l border-bg-hard/30">{{ omTMin(row) }}</td>
<td class="text-right px-1 font-mono text-xs text-orange font-bold">{{ omTMax(row) }}</td>
<td class="text-right px-1 font-mono text-xs text-aqua">{{ omRain(row) }}</td>
<td class="text-right px-1 text-blue text-xs border-l-2 border-bg-hard">{{ omTMin(row) }}</td>
<td class="text-right px-1 text-orange text-xs">{{ omTMax(row) }}</td>
<td class="text-right px-1 text-blue text-xs">{{ omRain(row) }}</td>
<td class="px-2">
<div class="flex items-center gap-1">
<img
v-if="row.open_meteo?.wmo != null"
:src="weatherIcon(row.open_meteo.wmo)"
class="w-5 h-5"
:alt="row.open_meteo.label"
/>
<span class="text-text-muted text-xs">{{ row.open_meteo?.label || '—' }}</span>
</div>
</td>
<td class="px-2">
<div class="flex items-center gap-2">
<img v-if="row.open_meteo?.wmo != null" :src="weatherIcon(row.open_meteo.wmo)" class="w-6 h-6 opacity-90 group-hover:scale-110 transition-transform" />
<span class="text-[11px] font-medium text-text-muted truncate max-w-[85px]">{{ row.open_meteo?.label || '—' }}</span>
</div>
</td>
<td class="px-2 text-xs border-l-2 border-bg-hard">
{{ lunarForDate(row.date)?.croissante_decroissante || '—' }}
</td>
<td class="px-2 text-xs">
{{ lunarForDate(row.date)?.montante_descendante || '' }}
</td>
<td class="px-2 text-xs" :class="typeColor(lunarForDate(row.date)?.type_jour || '')">
{{ lunarForDate(row.date)?.type_jour || '—' }}
</td>
</tr>
</tbody>
</table>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-3">
<!-- Élément -->
<span :title="lunarForDate(row.date)?.type_jour || ''" class="text-xl filter drop-shadow-sm">
{{ typeIcon(lunarForDate(row.date)?.type_jour || '') }}
</span>
<!-- Mouvement -->
<span :title="lunarForDate(row.date)?.montante_descendante || ''" class="text-sm font-bold">
{{ movementIcon(lunarForDate(row.date)?.montante_descendante || '') }}
</span>
<!-- Signe -->
<span :title="lunarForDate(row.date)?.signe || ''" class="text-base opacity-80">
{{ zodiacIcon(lunarForDate(row.date)?.signe || '') }}
</span>
<!-- Phase -->
<span class="text-xl leading-none" :title="lunarForDate(row.date)?.croissante_decroissante || ''">
{{ lunarIcon(lunarForDate(row.date)?.illumination ?? 0) }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Légende enrichie -->
<div class="flex justify-end">
<div class="bg-bg-hard/50 rounded-2xl p-4 border border-bg-soft inline-grid grid-cols-3 gap-x-8 gap-y-3 shadow-inner text-xs">
<div class="col-span-3 text-[9px] font-black uppercase tracking-widest text-text-muted mb-1 border-b border-bg-soft pb-1">Légende du Calendrier</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Éléments</div>
<div class="grid grid-cols-2 gap-1 text-[10px] text-text-muted">
<span>🥕 Racine</span> <span>🌿 Feuille</span>
<span>🌸 Fleur</span> <span>🍎 Fruit</span>
</div>
</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Mouvement</div>
<div class="grid grid-cols-1 gap-1 text-[10px] text-text-muted">
<span> <span class="font-bold text-yellow">Montante</span> (semis)</span>
<span> <span class="font-bold text-aqua">Descendante</span> (plant.)</span>
</div>
</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Signes</div>
<div class="text-[10px] text-text-muted font-mono leading-tight">
<br/>
</div>
</div>
</div>
</div>
</div>
<aside class="bg-bg-soft rounded-xl border border-bg-hard p-4">
<div v-if="!selectedMeteoRow" class="text-text-muted text-sm">Sélectionne un jour dans le tableau pour voir le détail.</div>
<!-- Détail Latéral -->
<aside class="space-y-6">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span>
Focus Journée
</h2>
<div v-else class="space-y-3">
<div v-if="selectedMeteoRow" class="card-jardin space-y-6 animate-fade-in">
<div>
<h3 class="text-text font-semibold">{{ formatDateLong(selectedMeteoRow.date) }}</h3>
<p class="text-text-muted text-xs">
{{ selectedMeteoRow.type === 'passe' ? 'Historique' : selectedMeteoRow.type === 'aujourd_hui' ? 'Aujourd\'hui' : 'Prévision' }}
</p>
<h3 class="text-text font-black text-lg leading-tight uppercase tracking-tighter">{{ formatDateLong(selectedMeteoRow.date) }}</h3>
<span class="badge badge-aqua !text-[9px] mt-1">{{ selectedSaint || 'Sainte Nature' }}</span>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-blue text-xs font-semibold mb-1">📡 Station locale</div>
<div class="text-xs text-text-muted space-y-1">
<div v-if="selectedMeteoRow.station && 'temp_ext' in selectedMeteoRow.station && selectedMeteoRow.station.temp_ext != null">
T° actuelle: <span class="text-text">{{ selectedMeteoRow.station.temp_ext.toFixed(1) }}°</span>
</div>
<div>T° min: <span class="text-text">{{ stationTMin(selectedMeteoRow) }}</span></div>
<div>T° max/actuelle: <span class="text-text">{{ stationTMax(selectedMeteoRow) }}</span></div>
<div>Pluie: <span class="text-text">{{ stationRain(selectedMeteoRow) }}</span></div>
<div v-if="selectedMeteoRow.station && 'vent_kmh' in selectedMeteoRow.station && selectedMeteoRow.station.vent_kmh != null">
Vent max: <span class="text-text">{{ selectedMeteoRow.station.vent_kmh }} km/h</span>
</div>
<div v-if="selectedMeteoRow.station && 'humidite' in selectedMeteoRow.station && selectedMeteoRow.station.humidite != null">
Humidité: <span class="text-text">{{ selectedMeteoRow.station.humidite }}%</span>
<div class="space-y-4">
<div v-if="selectedLunarDay" class="bg-bg-hard/50 rounded-xl p-3 border border-yellow/10">
<div class="text-[9px] font-black uppercase tracking-widest text-yellow mb-2">Cycle Lunaire</div>
<div class="grid grid-cols-2 gap-2 text-[11px]">
<div class="text-text-muted">Type: <span class="text-text font-bold">{{ selectedLunarDay.croissante_decroissante }}</span></div>
<div class="text-text-muted">Mouv.: <span class="text-text font-bold">{{ selectedLunarDay.montante_descendante }}</span></div>
<div class="text-text-muted">Signe: <span class="text-text font-bold">{{ selectedLunarDay.signe }}</span></div>
<div class="text-text-muted">Lumière: <span class="text-text font-bold">{{ selectedLunarDay.illumination }}%</span></div>
<div class="text-text-muted col-span-2">Élément: <span class="text-text font-bold">{{ selectedLunarDay.type_jour || '--' }}</span></div>
</div>
</div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-green text-xs font-semibold mb-1">🌐 Open-Meteo</div>
<div class="text-xs text-text-muted space-y-1">
<div>T° min: <span class="text-text">{{ omTMin(selectedMeteoRow) }}</span></div>
<div>T° max: <span class="text-text">{{ omTMax(selectedMeteoRow) }}</span></div>
<div>Pluie: <span class="text-text">{{ omRain(selectedMeteoRow) }}</span></div>
<div>État: <span class="text-text">{{ selectedMeteoRow.open_meteo?.label || '—' }}</span></div>
<div v-if="selectedMeteoRow.open_meteo?.vent_kmh != null">Vent: <span class="text-text">{{ selectedMeteoRow.open_meteo.vent_kmh }} km/h</span></div>
<div v-if="selectedMeteoRow.open_meteo?.sol_0cm != null">Sol 0cm: <span class="text-text">{{ selectedMeteoRow.open_meteo.sol_0cm }}°C</span></div>
<div v-if="selectedMeteoRow.open_meteo?.etp_mm != null">ETP: <span class="text-text">{{ selectedMeteoRow.open_meteo.etp_mm }} mm</span></div>
</div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-yellow text-xs font-semibold mb-1">🌙 Lunaire</div>
<div v-if="selectedLunarDay" class="text-xs text-text-muted space-y-1">
<div>Type lune: <span class="text-text">{{ selectedLunarDay.croissante_decroissante }}</span></div>
<div>Montante/Descendante: <span class="text-text">{{ selectedLunarDay.montante_descendante }}</span></div>
<div>Type de jour: <span :class="['font-semibold', typeColor(selectedLunarDay.type_jour)]">{{ selectedLunarDay.type_jour }}</span></div>
<div>Signe: <span class="text-text">{{ selectedLunarDay.signe }}</span></div>
<div>Illumination: <span class="text-text">{{ selectedLunarDay.illumination }}%</span></div>
<div>Saint: <span class="text-text">{{ selectedSaint || '—' }}</span></div>
</div>
<div v-else class="text-xs text-text-muted">Donnée lunaire indisponible pour cette date.</div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-orange text-xs font-semibold mb-1">📜 Dictons</div>
<div v-if="selectedDictons.length" class="space-y-2">
<p v-for="d in selectedDictons" :key="`detail-dicton-${d.id}`" class="text-xs text-text-muted italic">
<div class="text-[9px] font-black uppercase tracking-widest text-orange opacity-60">Sagesse du jour</div>
<p v-for="d in selectedDictons" :key="d.id" class="text-xs text-text-muted italic bg-bg-hard/30 p-2 rounded-lg leading-relaxed border-l-2 border-orange/30">
"{{ d.texte }}"
</p>
</div>
<div v-else class="text-xs text-text-muted">Aucun dicton trouvé pour ce jour.</div>
</div>
<div class="pt-4 border-t border-bg-hard flex justify-center">
<div class="text-center">
<div class="text-[9px] font-black uppercase tracking-widest text-green mb-2 opacity-60">Prévision Sol</div>
<div class="text-2xl font-mono text-text">{{ selectedMeteoRow.open_meteo?.sol_0cm?.toFixed(1) ?? '--' }}°C</div>
<div class="text-[10px] text-text-muted font-bold mt-1">à 0cm</div>
</div>
</div>
</div>
<div v-else class="card-jardin text-center py-12 opacity-30 border-dashed">
<p class="text-[10px] font-bold uppercase">Sélectionner une date</p>
</div>
</aside>
</div>
@@ -210,7 +235,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { lunarApi, type Dicton, type LunarDay } from '@/api/lunar'
import { meteoApi, type StationCurrent, type TableauRow } from '@/api/meteo'
const spanDays = 15
const spanDays = 10
const tableauRows = ref<TableauRow[]>([])
const loadingTableau = ref(false)
@@ -226,28 +251,19 @@ const rangeStart = computed(() => shiftIso(centerDate.value, -spanDays))
const rangeEnd = computed(() => shiftIso(centerDate.value, spanDays))
const saintsFallback: Record<string, string> = {
'04-23': 'Saint Georges',
'04-25': 'Saint Marc',
'05-11': 'Saint Mamert',
'05-12': 'Saint Pancrace',
'05-13': 'Saint Servais',
'05-14': 'Saint Boniface',
'05-19': 'Saint Yves',
'05-25': 'Saint Urbain',
'04-23': 'Saint Georges', '04-25': 'Saint Marc', '05-11': 'Saint Mamert',
'05-12': 'Saint Pancrace', '05-13': 'Saint Servais', '05-14': 'Saint Boniface',
'05-19': 'Saint Yves', '05-25': 'Saint Urbain',
}
const selectedMeteoRow = computed(() => tableauRows.value.find((r) => r.date === selectedMeteoDate.value) || null)
const selectedLunarDay = computed(() => lunarByDate.value[selectedMeteoDate.value] || null)
const currentOpenMeteo = computed(() => {
const today = tableauRows.value.find((r) => r.type === 'aujourd_hui')
return today?.open_meteo || null
})
const currentOpenMeteo = computed(() => tableauRows.value.find((r) => r.type === 'aujourd_hui')?.open_meteo || null)
const selectedSaint = computed(() => {
if (!selectedMeteoDate.value) return ''
if (selectedLunarDay.value?.saint_du_jour) return selectedLunarDay.value.saint_du_jour
const mmdd = selectedMeteoDate.value.slice(5)
return saintsFallback[mmdd] || ''
return saintsFallback[selectedMeteoDate.value.slice(5)] || ''
})
const selectedDictons = computed(() => {
@@ -255,10 +271,8 @@ const selectedDictons = computed(() => {
const month = monthFromIso(selectedMeteoDate.value)
const day = dayFromIso(selectedMeteoDate.value)
const rows = dictonsByMonth.value[month] || []
const exact = rows.filter((d) => d.jour === day)
if (exact.length) return exact
return rows.filter((d) => d.jour == null).slice(0, 3)
return exact.length ? exact : rows.filter((d) => d.jour == null).slice(0, 3)
})
function todayIso(): string {
@@ -272,21 +286,10 @@ function shiftIso(isoDate: string, days: number): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function shiftWindow(days: number) {
centerDate.value = shiftIso(centerDate.value, days)
}
function goToday() {
centerDate.value = todayIso()
}
function monthFromIso(isoDate: string): number {
return Number(isoDate.slice(5, 7))
}
function dayFromIso(isoDate: string): number {
return Number(isoDate.slice(8, 10))
}
function shiftWindow(days: number) { centerDate.value = shiftIso(centerDate.value, days) }
function goToday() { centerDate.value = todayIso() }
function monthFromIso(isoDate: string): number { return Number(isoDate.slice(5, 7)) }
function dayFromIso(isoDate: string): number { return Number(isoDate.slice(8, 10)) }
function selectMeteoDate(isoDate: string) {
selectedMeteoDate.value = isoDate
@@ -306,124 +309,77 @@ async function ensureDictonsMonth(month: number) {
async function loadLunarForTableau() {
const months = Array.from(new Set(tableauRows.value.map((r) => r.date.slice(0, 7))))
const map: Record<string, LunarDay> = {}
for (const month of months) {
try {
const days = await lunarApi.getMonth(month)
for (const d of days) map[d.date] = d
} catch {
// Mois indisponible: on laisse les cellules lunaires vides
}
} catch {}
}
lunarByDate.value = map
}
async function loadTableau() {
loadingTableau.value = true
try {
const res = await meteoApi.getTableau({
center_date: centerDate.value,
span: spanDays,
})
const res = await meteoApi.getTableau({ center_date: centerDate.value, span: spanDays })
tableauRows.value = res.rows || []
await loadLunarForTableau()
const selectedStillVisible = tableauRows.value.some((r) => r.date === selectedMeteoDate.value)
if (selectedStillVisible) return
const todayRow = tableauRows.value.find((r) => r.type === 'aujourd_hui')
if (todayRow) {
selectMeteoDate(todayRow.date)
} else if (tableauRows.value.length) {
selectMeteoDate(tableauRows.value[0].date)
}
} catch {
tableauRows.value = []
lunarByDate.value = {}
if (todayRow && !selectedMeteoDate.value) selectMeteoDate(todayRow.date)
} finally {
loadingTableau.value = false
}
}
async function loadStationCurrent() {
try {
stationCurrent.value = await meteoApi.getStationCurrent()
} catch {
stationCurrent.value = null
}
try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null }
}
function lunarForDate(isoDate: string): LunarDay | null {
return lunarByDate.value[isoDate] || null
}
function lunarForDate(isoDate: string): LunarDay | null { return lunarByDate.value[isoDate] || null }
function stationTMin(row: TableauRow): string {
if (row.station && 't_min' in row.station && row.station.t_min != null) return `${row.station.t_min.toFixed(1)}°`
return '—'
return (row.station && 't_min' in row.station && row.station.t_min != null) ? `${row.station.t_min.toFixed(1)}°` : '—'
}
function stationTMax(row: TableauRow): string {
if (row.station && 't_max' in row.station && row.station.t_max != null) return `${row.station.t_max.toFixed(1)}°`
if (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null) {
return `${row.station.temp_ext.toFixed(1)}° act.`
}
if (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null) return `${row.station.temp_ext.toFixed(1)}°`
return '—'
}
function stationRain(row: TableauRow): string {
if (row.station && row.station.pluie_mm != null) return String(row.station.pluie_mm)
return '—'
}
function omTMin(row: TableauRow): string {
return row.open_meteo?.t_min != null ? `${row.open_meteo.t_min.toFixed(1)}°` : '—'
}
function omTMax(row: TableauRow): string {
return row.open_meteo?.t_max != null ? `${row.open_meteo.t_max.toFixed(1)}°` : '—'
}
function omRain(row: TableauRow): string {
return row.open_meteo?.pluie_mm != null ? String(row.open_meteo.pluie_mm) : '—'
}
function stationRain(row: TableauRow): string { return row.station?.pluie_mm != null ? String(row.station.pluie_mm) : '—' }
function omTMin(row: TableauRow): string { return row.open_meteo?.t_min != null ? `${row.open_meteo.t_min.toFixed(1)}°` : '—' }
function omTMax(row: TableauRow): string { return row.open_meteo?.t_max != null ? `${row.open_meteo.t_max.toFixed(1)}°` : '—' }
function omRain(row: TableauRow): string { return row.open_meteo?.pluie_mm != null ? String(row.open_meteo.pluie_mm) : '—' }
function weatherIcon(code: number): string {
const available = [0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99]
const closest = available.reduce((prev, curr) =>
Math.abs(curr - code) < Math.abs(prev - code) ? curr : prev,
)
const closest = available.reduce((p, c) => Math.abs(c - code) < Math.abs(p - code) ? c : p)
return `/icons/weather/${closest}.svg`
}
function typeColor(type: string): string {
return ({ Racine: 'text-yellow', Feuille: 'text-green', Fleur: 'text-orange', Fruit: 'text-red' } as Record<string, string>)[type] || 'text-text-muted'
function lunarIcon(illu: number): string {
if (illu < 5) return '🌑'; if (illu < 25) return '🌒'; if (illu < 45) return '🌓'; if (illu < 65) return '🌔';
if (illu < 85) return '🌕'; if (illu < 95) return '🌖'; if (illu < 98) return '🌗'; return '🌘'
}
function formatDate(dateStr: string): string {
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
function typeIcon(t: string): string {
return ({ Racine: '🥕', Feuille: '🌿', Fleur: '🌸', Fruit: '🍎' } as any)[t] || '—'
}
function formatDateLong(dateStr: string): string {
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
function movementIcon(md: string): string { return md === 'Montante' ? '↗️' : md === 'Descendante' ? '↘️' : '' }
function zodiacIcon(s: string): string {
return ({
'Bélier': '♈', 'Taureau': '♉', 'Gémeaux': '♊', 'Cancer': '',
'Lion': '♌', 'Vierge': '♍', 'Balance': '♎', 'Scorpion': '♏',
'Sagittaire': '♐', 'Capricorne': '♑', 'Verseau': '♒', 'Poissons': '♓'
} as any)[s] || ''
}
watch(centerDate, () => {
void loadTableau()
})
function formatDate(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) }
function formatDateLong(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }
watch(selectedMeteoDate, (iso) => {
if (!iso) return
void ensureDictonsMonth(monthFromIso(iso))
})
onMounted(() => {
void loadTableau()
void loadStationCurrent()
})
watch(centerDate, loadTableau)
onMounted(() => { void loadTableau(); void loadStationCurrent() })
</script>

View File

@@ -1,82 +1,147 @@
<template>
<div class="p-4 max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1>
<section class="mb-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2>
<div v-if="!pendingTasks.length" class="text-text-muted text-sm py-2">Aucune tâche en attente.</div>
<div
v-for="t in pendingTasks"
:key="t.id"
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard"
>
<span :class="{
'text-red': t.priorite === 'haute',
'text-yellow': t.priorite === 'normale',
'text-text-muted': t.priorite === 'basse'
}"></span>
<span class="text-text text-sm flex-1">{{ t.titre }}</span>
<button
class="text-xs text-green hover:underline px-2"
@click="tasksStore.updateStatut(t.id!, 'fait')"
> Fait</button>
<div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-green tracking-tight">Tableau de bord</h1>
<div class="text-text-muted text-xs font-medium bg-bg-hard px-3 py-1 rounded-full border border-bg-soft">
🌿 {{ new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }}
</div>
</section>
</div>
<section class="mb-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Météo</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Section Tâches -->
<section class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span>
Tâches prioritaires
</h2>
<RouterLink to="/taches" class="text-xs text-yellow hover:underline font-bold uppercase tracking-wide">Voir tout</RouterLink>
</div>
<div v-if="stationCurrent || meteo7j.length" class="bg-bg-soft rounded-xl p-3 border border-bg-hard mb-3">
<div class="text-text-muted text-xs mb-1">Condition actuelle</div>
<div class="flex items-center gap-3">
<img
v-if="meteoCurrent?.wmo != null"
:src="weatherIcon(meteoCurrent.wmo)"
class="w-8 h-8"
:alt="meteoCurrent.label || 'Météo'"
/>
<div class="text-sm text-text">
<div>{{ meteoCurrent?.label || '—' }}</div>
<div class="text-text-muted text-xs">
{{ stationCurrent?.temp_ext != null ? `${stationCurrent.temp_ext.toFixed(1)}°C` : 'Temp. indisponible' }}
<span v-if="stationCurrent?.date_heure"> · relevé {{ stationCurrent.date_heure.slice(11, 16) }}</span>
<div v-if="!pendingTasks.length" class="card-jardin text-center py-10 opacity-50">
<p class="text-text-muted text-sm">Aucune tâche en attente. Profitez du jardin ! </p>
</div>
<div class="space-y-3">
<div
v-for="t in pendingTasks"
:key="t.id"
class="card-jardin flex items-center gap-4 group"
>
<div :class="[
'w-2 h-10 rounded-full shrink-0',
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
]"></div>
<div class="flex-1 min-w-0">
<div class="text-text font-semibold text-sm truncate">{{ t.titre }}</div>
<div class="text-text-muted text-[10px] uppercase font-bold tracking-tight">Priorité {{ t.priorite }}</div>
</div>
<button
class="btn-outline py-1 px-3 border-green/30 text-green hover:bg-green/10"
@click="tasksStore.updateStatut(t.id!, 'fait')"
>Terminer</button>
</div>
</div>
</div>
</section>
<div v-if="meteo7j.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2">
<div v-for="day in meteo7j" :key="day.date"
class="bg-bg-soft rounded-xl p-3 border border-bg-hard flex flex-col items-center gap-1 min-w-0">
<div class="text-text-muted text-xs">{{ formatDate(day.date || '') }}</div>
<!-- Section Météo Actuelle -->
<section>
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue"></span>
Météo Locale
</h2>
<div class="card-jardin border-blue/20 bg-gradient-to-br from-bg-soft to-bg-hard relative overflow-hidden">
<div v-if="meteoCurrent" class="relative z-10 flex flex-col items-center py-4">
<img
v-if="meteoCurrent?.wmo != null"
:src="weatherIcon(meteoCurrent.wmo)"
class="drop-shadow-2xl mb-2"
:style="{ width: 'calc(var(--ui-weather-icon-size, 48px) * 2)', height: 'calc(var(--ui-weather-icon-size, 48px) * 2)' }"
:alt="meteoCurrent.label || 'Météo'"
/>
<div class="text-3xl font-bold text-text mb-1">
{{ stationCurrent?.temp_ext != null ? `${stationCurrent.temp_ext.toFixed(1)}°C` : '—°' }}
</div>
<div class="text-sm font-bold text-blue uppercase tracking-widest mb-4">{{ meteoCurrent?.label || '—' }}</div>
<div class="grid grid-cols-2 gap-4 w-full pt-4 border-t border-bg-hard/50">
<div class="text-center">
<div class="text-[10px] text-text-muted uppercase font-bold">Humidité</div>
<div class="text-sm text-blue">{{ stationCurrent?.humidite ?? '--' }}%</div>
</div>
<div class="text-center">
<div class="text-[10px] text-text-muted uppercase font-bold">Vent</div>
<div class="text-sm text-text">{{ stationCurrent?.vent_kmh ?? '--' }} <span class="text-[10px]">km/h</span></div>
</div>
</div>
</div>
<div v-else class="text-center py-10 opacity-50 text-sm">Connexion météo...</div>
</div>
</section>
</div>
<!-- Section Prévisions 7j -->
<section>
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Prévisions de la semaine
</h2>
<div v-if="meteo7j.length" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<div v-for="(day, index) in meteo7j" :key="day.date"
:class="['card-jardin flex flex-col items-center gap-2 text-center transition-all group hover:-translate-y-1',
index === 0 ? 'border-yellow/30 bg-yellow/5' : '']">
<div class="text-[10px] font-bold uppercase tracking-tighter" :class="index === 0 ? 'text-yellow' : 'text-text-muted'">
{{ index === 0 ? 'Aujourd\'hui' : formatDate(day.date || '') }}
</div>
<img
v-if="day.wmo != null"
:src="weatherIcon(day.wmo)"
class="w-8 h-8"
class="group-hover:scale-110 transition-transform"
:style="{ width: 'var(--ui-weather-icon-size, 48px)', height: 'var(--ui-weather-icon-size, 48px)' }"
:alt="day.label || 'Météo'"
/>
<div v-else class="text-2xl"></div>
<div class="text-[11px] text-center text-text-muted leading-tight min-h-[30px]">{{ day.label || '—' }}</div>
<div class="flex gap-1 text-xs">
<span class="text-orange">{{ day.t_max != null ? day.t_max.toFixed(0) : '—' }}°</span>
<span class="text-blue">{{ day.t_min != null ? day.t_min.toFixed(0) : '—' }}°</span>
<div class="text-[10px] text-text-muted font-medium line-clamp-1 h-3">{{ day.label || '—' }}</div>
<div class="flex gap-2 text-xs font-bold pt-1">
<span class="text-orange">{{ day.t_max?.toFixed(1) }}°</span>
<span class="text-blue">{{ day.t_min?.toFixed(1) }}°</span>
</div>
</div>
</div>
<div v-else class="text-text-muted text-sm py-2">Prévisions indisponibles.</div>
</section>
<!-- Section Jardins -->
<section>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2>
<div v-if="gardensStore.loading" class="text-text-muted text-sm">Chargement...</div>
<div
v-for="g in gardensStore.gardens"
:key="g.id"
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors"
@click="router.push(`/jardins/${g.id}`)"
>
<span class="text-text font-medium">{{ g.nom }}</span>
<span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span>
<div class="flex items-center justify-between mb-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-orange"></span>
Mes Jardins
</h2>
<button class="btn-primary py-1 px-4 text-xs" @click="router.push('/jardins')">Gérer</button>
</div>
<div v-if="gardensStore.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i" class="card-jardin h-24 animate-pulse opacity-20"></div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="g in gardensStore.gardens"
:key="g.id"
class="card-jardin flex flex-col justify-between group cursor-pointer hover:border-green/50"
@click="router.push(`/jardins/${g.id}`)"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl" :style="{ fontSize: 'var(--ui-dashboard-icon-size, 24px)' }">🪴</span>
<h3 class="text-text font-bold text-lg group-hover:text-green transition-colors">{{ g.nom }}</h3>
</div>
<span class="badge badge-yellow">{{ g.type?.replace('_', ' ') }}</span>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-[10px] text-text-muted uppercase font-bold tracking-widest">{{ g.exposition }} · {{ g.surface_m2 }}</span>
<div class="text-green text-xl opacity-0 group-hover:opacity-100 transition-all"></div>
</div>
</div>
</div>
</section>
</div>
@@ -84,14 +149,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, RouterLink } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'
import { useTasksStore } from '@/stores/tasks'
import { meteoApi, type OpenMeteoDay, type StationCurrent } from '@/api/meteo'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const gardensStore = useGardensStore()
const tasksStore = useTasksStore()
const toast = useToast()
const pendingTasks = computed(() => tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5))
const meteo7j = ref<OpenMeteoDay[]>([])
@@ -100,7 +167,8 @@ const meteoCurrent = computed(() => meteo7j.value[0] || null)
function formatDate(s: string) {
if (!s) return '—'
return new Date(s + 'T12:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
const date = new Date(s + 'T12:00:00')
return date.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric' })
}
function weatherIcon(code: number): string {
@@ -112,8 +180,11 @@ function weatherIcon(code: number): string {
}
onMounted(async () => {
gardensStore.fetchAll()
tasksStore.fetchAll()
// Chargement des données principales (silencieux si erreur)
try { await gardensStore.fetchAll() } catch { toast.warning('Jardins non disponibles') }
try { await tasksStore.fetchAll() } catch { toast.warning('Tâches non disponibles') }
// Météo : fallback silencieux, pas de toast intrusif
try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null }
try { const r = await meteoApi.getPrevisions(7); meteo7j.value = r.days.slice(0, 7) } catch { meteo7j.value = [] }
})

View File

@@ -3,7 +3,9 @@
<button class="text-text-muted text-sm mb-4 hover:text-text" @click="router.back()"> Retour</button>
<div v-if="garden">
<h1 class="text-2xl font-bold text-green mb-1">{{ garden.nom }}</h1>
<div class="flex items-start justify-between mb-1">
<h1 class="text-2xl font-bold text-green">{{ garden.nom }}</h1>
</div>
<p class="text-text-muted text-sm mb-6">
{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}
<span v-if="garden.sol_type"> · Sol : {{ garden.sol_type }}</span>
@@ -39,21 +41,81 @@
class="w-full max-h-72 object-cover rounded-lg border border-bg-hard bg-bg-soft" />
</div>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
</h2>
<!-- En-tête grille -->
<div class="flex items-center justify-between mb-3">
<h2 class="text-text-muted text-xs uppercase tracking-widest">
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
</h2>
<div class="flex items-center gap-2">
<span v-if="editMode" class="text-xs text-orange">Cliquez pour activer/désactiver une zone</span>
<button @click="editMode = !editMode"
:class="['px-3 py-1 rounded-full text-xs font-bold border transition-all',
editMode ? 'bg-orange text-bg border-orange' : 'border-bg-soft text-text-muted hover:border-orange hover:text-orange']">
{{ editMode ? '✓ Terminer' : '✏️ Éditer zones' }}
</button>
</div>
</div>
<!-- Légende -->
<div class="flex flex-wrap gap-4 mb-3 text-[10px] text-text-muted font-bold uppercase tracking-wider">
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-bg border border-bg-soft inline-block"></span>Libre
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-green/20 border border-green/60 inline-block"></span>Planté
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-red/20 border border-red/40 inline-block"></span>Non cultivable
</span>
</div>
<div class="overflow-x-auto pb-2">
<div
class="grid gap-1 w-max"
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`"
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 56px)`"
>
<div
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
class="w-[52px] h-[52px] bg-bg-soft border border-bg-hard rounded-md flex items-center justify-center text-xs text-text-muted cursor-pointer hover:border-green transition-colors select-none"
:class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }"
:title="cell.libelle"
:class="[
'w-[56px] h-[56px] border rounded-md flex flex-col items-center justify-center text-[10px] select-none transition-all overflow-hidden',
editMode && cell.etat !== 'occupe' && !getCellPlanting(cell) ? 'cursor-pointer' : '',
getCellPlanting(cell)
? 'bg-green/10 border-green/60 text-green'
: cell.etat === 'non_cultivable'
? 'bg-red/10 border-red/30 text-red/50'
: 'bg-bg-soft border-bg-hard text-text-muted',
editMode && !getCellPlanting(cell) && cell.etat !== 'occupe'
? (cell.etat === 'non_cultivable' ? 'hover:bg-red/20 hover:border-red/60' : 'hover:border-orange hover:bg-orange/10')
: ''
]"
:title="getCellTitle(cell)"
@click="editMode && !getCellPlanting(cell) && cell.etat !== 'occupe' ? toggleNonCultivable(cell) : undefined"
>
{{ cell.libelle }}
<span :class="['font-mono leading-none', getCellPlanting(cell) ? 'text-[9px] font-bold' : '']">
{{ cell.libelle }}
</span>
<span v-if="getCellPlanting(cell)" class="text-[8px] text-green/80 leading-none mt-0.5 px-0.5 text-center truncate w-full">
{{ plantShortName(getCellPlanting(cell)!) }}
</span>
<span v-else-if="cell.etat === 'non_cultivable'" class="text-[9px] leading-none"></span>
</div>
</div>
</div>
<!-- Saving indicator -->
<div v-if="saving" class="mt-2 text-xs text-text-muted animate-pulse">Enregistrement</div>
<!-- Résumé plantations actives -->
<div v-if="activePlantings.length" class="mt-6">
<h3 class="text-text-muted text-xs uppercase tracking-widest mb-2">Plantations actives ({{ activePlantings.length }})</h3>
<div class="flex flex-wrap gap-2">
<div v-for="p in activePlantings" :key="p.id"
class="bg-bg-soft border border-green/30 rounded px-2 py-1 text-xs text-green flex items-center gap-1.5">
<span class="text-text-muted font-mono text-[10px]">
{{ plantCellLabel(p) }}
</span>
{{ plantName(p) }}
<span class="text-text-muted">· {{ p.statut }}</span>
</div>
</div>
</div>
@@ -66,11 +128,17 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { gardensApi, type Garden, type GardenCell } from '@/api/gardens'
import { plantingsApi, type Planting } from '@/api/plantings'
import { plantsApi, type Plant } from '@/api/plants'
const route = useRoute()
const router = useRouter()
const garden = ref<Garden | null>(null)
const cells = ref<GardenCell[]>([])
const plantings = ref<Planting[]>([])
const plants = ref<Plant[]>([])
const editMode = ref(false)
const saving = ref(false)
const displayCells = computed(() => {
if (!garden.value) return []
@@ -88,9 +156,87 @@ const displayCells = computed(() => {
return result
})
// Plantations actives (ni terminées ni échouées) pour ce jardin
const activePlantings = computed(() =>
plantings.value.filter(p =>
p.garden_id === garden.value?.id &&
p.statut !== 'termine' &&
p.statut !== 'echoue'
)
)
// Map cellId → Planting active
const activePlantingsByCellId = computed(() => {
const map = new Map<number, Planting>()
for (const p of activePlantings.value) {
if (p.cell_ids?.length) {
p.cell_ids.forEach(cid => map.set(cid, p))
} else if (p.cell_id) {
map.set(p.cell_id, p)
}
}
return map
})
function getCellPlanting(cell: GardenCell): Planting | undefined {
if (cell.id == null) return undefined
return activePlantingsByCellId.value.get(cell.id)
}
function plantName(p: Planting): string {
const plant = plants.value.find(pl => pl.id === p.variety_id)
return plant?.nom_commun ?? `Plante #${p.variety_id}`
}
function plantShortName(p: Planting): string {
return plantName(p).slice(0, 8)
}
function plantCellLabel(p: Planting): string {
const ids = p.cell_ids?.length ? p.cell_ids : (p.cell_id ? [p.cell_id] : [])
if (!ids.length) return '—'
return ids
.map(cid => cells.value.find(c => c.id === cid)?.libelle ?? `#${cid}`)
.join(', ')
}
function getCellTitle(cell: GardenCell): string {
const planting = getCellPlanting(cell)
if (planting) return `Planté : ${plantName(planting)} (${planting.statut})`
if (cell.etat === 'non_cultivable') return 'Non cultivable — cliquer pour rendre cultivable'
if (editMode.value) return 'Marquer comme non cultivable'
return cell.libelle ?? ''
}
async function toggleNonCultivable(cell: GardenCell) {
if (!garden.value?.id || saving.value) return
const newEtat = cell.etat === 'non_cultivable' ? 'libre' : 'non_cultivable'
saving.value = true
try {
if (cell.id) {
const updated = await gardensApi.updateCell(garden.value.id, cell.id, { ...cell, etat: newEtat })
const idx = cells.value.findIndex(c => c.id === cell.id)
if (idx !== -1) cells.value[idx] = updated
} else {
const created = await gardensApi.createCell(garden.value.id, {
col: cell.col, row: cell.row,
libelle: cell.libelle,
etat: newEtat,
garden_id: garden.value.id,
})
cells.value.push(created)
}
} catch { /* L'intercepteur affiche l'erreur */ }
finally { saving.value = false }
}
onMounted(async () => {
const id = Number(route.params.id)
garden.value = await gardensApi.get(id)
cells.value = await gardensApi.cells(id)
;[garden.value, cells.value, plantings.value, plants.value] = await Promise.all([
gardensApi.get(id),
gardensApi.cells(id),
plantingsApi.list(),
plantsApi.list(),
])
})
</script>

View File

@@ -1,169 +1,243 @@
<template>
<div class="p-4 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">🪴 Jardins</h1>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
@click="openCreate">+ Nouveau</button>
</div>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-for="g in store.gardens" :key="g.id"
class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard flex items-center gap-3 group">
<div class="flex-1 cursor-pointer" @click="router.push(`/jardins/${g.id}`)">
<div class="text-text font-medium group-hover:text-green transition-colors">{{ g.nom }}</div>
<div class="text-text-muted text-xs mt-1">
{{ typeLabel(g.type) }} · {{ g.grille_largeur }}×{{ g.grille_hauteur }} cases
<span v-if="g.exposition"> · {{ g.exposition }}</span>
<span v-if="g.carre_potager && g.carre_x_cm != null && g.carre_y_cm != null">
· Carré potager {{ g.carre_x_cm }}×{{ g.carre_y_cm }} cm
</span>
<span v-if="g.longueur_m != null && g.largeur_m != null"> · {{ g.longueur_m }}×{{ g.largeur_m }} m</span>
<span v-if="g.surface_m2 != null"> · {{ g.surface_m2 }} m²</span>
</div>
<div class="p-4 max-w-[1800px] mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Mes Jardins</h1>
<p class="text-text-muted text-xs mt-1">Gérez vos espaces de culture et leurs dimensions.</p>
</div>
<button @click="startEdit(g)" class="text-yellow text-xs hover:underline px-2">Édit.</button>
<button @click="store.remove(g.id!)" class="text-text-muted hover:text-red text-sm px-2"></button>
<button class="btn-primary flex items-center gap-2" @click="openCreate">
<span class="text-lg leading-none">+</span> Nouveau jardin
</button>
</div>
<div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8">
Aucun jardin. Créez-en un !
<div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<div v-for="i in 4" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div>
<!-- Modal création / édition -->
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-4xl border border-bg-soft max-h-[90vh] overflow-y-auto">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier le jardin' : 'Nouveau jardin' }}</h2>
<form @submit.prevent="submit" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-6">
<div v-for="g in store.gardens" :key="g.id"
class="card-jardin flex flex-col justify-between group relative overflow-hidden h-full min-h-[200px]">
<!-- Image de fond subtile si dispo -->
<div v-if="g.photo_parcelle" class="absolute inset-0 opacity-10 pointer-events-none">
<img :src="g.photo_parcelle" class="w-full h-full object-cover" />
</div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-4">
<div class="min-w-0 flex-1">
<h2 class="text-text font-bold text-xl group-hover:text-green transition-colors cursor-pointer truncate"
@click="router.push(`/jardins/${g.id}`)">
{{ g.nom }}
</h2>
<div class="flex flex-wrap items-center gap-2 mt-1">
<span class="badge badge-yellow">{{ typeLabel(g.type) }}</span>
<span v-if="g.exposition" class="badge badge-blue">{{ g.exposition }}</span>
</div>
</div>
<div class="flex gap-1 shrink-0 ml-2">
<button @click="startEdit(g)" class="p-2 text-text-muted hover:text-yellow transition-colors" title="Modifier">
</button>
<button @click="removeGarden(g.id!)" class="p-2 text-text-muted hover:text-red transition-colors" title="Supprimer">
</button>
</div>
</div>
<p v-if="g.description" class="text-text-muted text-sm mb-4 line-clamp-2 italic leading-relaxed">
{{ g.description }}
</p>
<div class="grid grid-cols-2 gap-4 py-3 border-t border-bg-hard/50">
<div>
<div class="text-[10px] text-text-muted uppercase font-bold tracking-widest">Grille</div>
<div class="text-sm text-text font-mono">
{{ g.grille_largeur }}×{{ g.grille_hauteur }} <span class="text-text-muted text-[10px]">cases</span>
</div>
</div>
<div>
<div class="text-[10px] text-text-muted uppercase font-bold tracking-widest">Surface</div>
<div class="text-sm text-text font-mono">
{{ g.surface_m2 || '--' }} <span class="text-text-muted text-[10px]"></span>
</div>
</div>
</div>
</div>
<button @click="router.push(`/jardins/${g.id}`)"
class="btn-outline w-full mt-4 border-green/20 text-green group-hover:bg-green/10 flex items-center justify-center gap-2 font-bold uppercase text-[10px] tracking-widest">
Ouvrir le plan
</button>
</div>
</div>
<div v-if="!store.loading && !store.gardens.length" class="card-jardin text-center py-16 opacity-50">
<div class="text-4xl mb-4">🪴</div>
<p class="text-text-muted text-sm">Vous n'avez pas encore créé de jardin.</p>
<button class="text-green hover:underline mt-2 font-bold" @click="openCreate">Commencer maintenant</button>
</div>
<!-- Modal création / édition ÉLARGIE -->
<div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-6xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-8 border-b border-bg-soft pb-6">
<div>
<label class="text-text-muted text-xs block mb-1">Nom *</label>
<input v-model="form.nom" required
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
<h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'espace' : 'Nouvel espace de culture' }}</h2>
<p class="text-text-muted text-xs mt-1 italic">Configurez les paramètres physiques et géographiques de votre jardin.</p>
</div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Description</label>
<textarea v-model="form.description" rows="2"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type</label>
<select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="plein_air">Plein air</option>
<option value="serre">Serre</option>
<option value="tunnel">Tunnel</option>
<option value="bac">Bac / Pot</option>
</select>
</div>
<div class="bg-bg rounded border border-bg-hard p-3 lg:col-span-2">
<label class="inline-flex items-center gap-2 text-sm text-text">
<input v-model="form.carre_potager" type="checkbox" class="accent-green" />
Carré potager
</label>
<p class="text-text-muted text-[11px] mt-1">Active les dimensions X/Y en centimètres pour un bac carré.</p>
</div>
<div v-if="form.carre_potager" class="grid grid-cols-1 sm:grid-cols-2 gap-3 lg:col-span-2">
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
</div>
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
<!-- Colonne 1 : Identité -->
<div class="space-y-6">
<h3 class="text-yellow font-bold text-xs uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span> Identité
</h3>
<div>
<label class="text-text-muted text-xs block mb-1">Dimension X (cm)</label>
<input v-model.number="form.carre_x_cm" type="number" min="1" step="1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Nom de l'espace *</label>
<input v-model="form.nom" required placeholder="Ex: Serre de semis"
class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-4 text-text text-sm focus:border-green outline-none transition-all shadow-inner" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Dimension Y (cm)</label>
<input v-model.number="form.carre_y_cm" type="number" min="1" step="1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Description libre</label>
<textarea v-model="form.description" rows="1" placeholder="Notes sur l'exposition réelle, l'historique..."
@input="autoResize"
class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-4 text-text text-sm focus:border-green outline-none resize-none transition-all shadow-inner overflow-hidden" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Type</label>
<select v-model="form.type" class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-3 text-text text-sm outline-none focus:border-green appearance-none">
<option value="plein_air">Plein air</option>
<option value="serre">Serre</option>
<option value="tunnel">Tunnel</option>
<option value="bac">Bac / Pot</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Exposition</label>
<select v-model="form.exposition" class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-3 text-text text-sm outline-none focus:border-green appearance-none">
<option value="">— Choisir —</option>
<option value="Nord">Nord</option>
<option value="Est">Est</option>
<option value="Sud">Sud</option>
<option value="Ouest">Ouest</option>
<option value="Sud-Est">Sud-Est</option>
<option value="Sud-Ouest">Sud-Ouest</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Largeur grille</label>
<input v-model.number="form.grille_largeur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<!-- Colonne 2 : Géométrie & Grille -->
<div class="space-y-6">
<h3 class="text-aqua font-bold text-xs uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span> Géométrie & Grille
</h3>
<div class="bg-bg-soft/30 rounded-3xl p-6 border border-bg-soft space-y-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Colonnes (X)</label>
<input v-model.number="form.grille_largeur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm font-mono" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Lignes (Y)</label>
<input v-model.number="form.grille_hauteur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm font-mono" />
</div>
</div>
<div class="pt-4 border-t border-bg-hard">
<label class="inline-flex items-center gap-3 text-sm text-text cursor-pointer group">
<div class="relative">
<input v-model="form.carre_potager" type="checkbox" class="sr-only peer" />
<div class="w-12 h-6 bg-bg-hard rounded-full peer peer-checked:bg-green transition-colors"></div>
<div class="absolute left-1 top-1 w-4 h-4 bg-text-muted peer-checked:bg-bg peer-checked:translate-x-6 rounded-full transition-all"></div>
</div>
<span class="font-bold uppercase text-[10px] tracking-widest group-hover:text-green">Carré potager spécialisé</span>
</label>
</div>
<div v-if="form.carre_potager" class="grid grid-cols-2 gap-4 animate-fade-in">
<div>
<label class="text-text-muted text-[10px] block mb-1 font-bold">X (cm)</label>
<input v-model.number="form.carre_x_cm" type="number" min="1"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-[10px] block mb-1 font-bold">Y (cm)</label>
<input v-model.number="form.carre_y_cm" type="number" min="1"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm" />
</div>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Hauteur grille</label>
<input v-model.number="form.grille_hauteur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Surface (m²)</label>
<input v-model.number="form.surface_m2" type="number" step="0.1" placeholder="Auto"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm shadow-inner font-mono" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Sol</label>
<select v-model="form.sol_type" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none appearance-none">
<option value="">— Type —</option>
<option value="argileux">Argileux</option>
<option value="limoneux">Limoneux</option>
<option value="sableux">Sableux</option>
<option value="calcaire">Calcaire</option>
<option value="humifère">Humifère</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 lg:col-span-2">
<div>
<label class="text-text-muted text-xs block mb-1">Longueur (m)</label>
<input v-model.number="form.longueur_m" type="number" min="0" step="0.1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<!-- Colonne 3 : Localisation & Photo -->
<div class="space-y-6">
<h3 class="text-orange font-bold text-xs uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-orange"></span> Localisation & Visuel
</h3>
<div class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Adresse / Repère</label>
<input v-model="form.adresse" placeholder="Coordonnées ou adresse..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-green shadow-inner" />
</div>
<div class="grid grid-cols-2 gap-4">
<input v-model.number="form.latitude" type="number" step="0.000001" placeholder="Lat."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-xs font-mono" />
<input v-model.number="form.longitude" type="number" step="0.000001" placeholder="Long."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-xs font-mono" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Largeur (m)</label>
<input v-model.number="form.largeur_m" type="number" min="0" step="0.1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Surface ()</label>
<input v-model.number="form.surface_m2" type="number" min="0" step="0.1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
<div class="bg-bg-soft/30 rounded-3xl p-4 border border-bg-soft">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-3">Photo de l'espace</label>
<div class="relative group aspect-video rounded-2xl overflow-hidden bg-bg border-2 border-dashed border-bg-soft flex items-center justify-center cursor-pointer hover:border-green transition-all">
<input type="file" accept="image/*" @change="onPhotoSelected" class="absolute inset-0 opacity-0 cursor-pointer z-20" />
<img v-if="photoPreview" :src="photoPreview" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center">
<span class="text-3xl block mb-2">📸</span>
<span class="text-[10px] font-bold uppercase text-text-muted">Importer une photo</span>
</div>
</div>
</div>
</div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Photo parcelle (image)</label>
<input type="file" accept="image/*" @change="onPhotoSelected"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
<div v-if="photoPreview" class="mt-2">
<img :src="photoPreview" alt="Prévisualisation parcelle"
class="w-full max-h-44 object-cover rounded border border-bg-hard bg-bg-soft" />
<!-- Actions -->
<div class="md:col-span-2 xl:col-span-3 flex justify-between items-center pt-8 border-t border-bg-soft mt-4">
<button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6" @click="closeForm">Abandonner</button>
<div class="flex gap-4">
<button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl" :disabled="submitting">
{{ submitting ? 'Enregistrement…' : (editId ? 'Sauvegarder les modifications' : 'Créer cet espace de culture') }}
</button>
</div>
</div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Adresse / localisation</label>
<input v-model="form.adresse"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 lg:col-span-2">
<div>
<label class="text-text-muted text-xs block mb-1">Latitude</label>
<input v-model.number="form.latitude" type="number" step="0.000001"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Longitude</label>
<input v-model.number="form.longitude" type="number" step="0.000001"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Altitude (m)</label>
<input v-model.number="form.altitude" type="number" step="1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 lg:col-span-2">
<div>
<label class="text-text-muted text-xs block mb-1">Exposition</label>
<select v-model="form.exposition" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value=""></option>
<option value="Nord">Nord</option>
<option value="Est">Est</option>
<option value="Sud">Sud</option>
<option value="Ouest">Ouest</option>
<option value="Sud-Est">Sud-Est</option>
<option value="Sud-Ouest">Sud-Ouest</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type de sol</label>
<select v-model="form.sol_type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value=""></option>
<option value="argileux">Argileux</option>
<option value="limoneux">Limoneux</option>
<option value="sableux">Sableux</option>
<option value="calcaire">Calcaire</option>
<option value="humifère">Humifère</option>
<option value="mixte">Mixte</option>
</select>
</div>
</div>
<div class="flex gap-2 mt-2 lg:col-span-2">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">
{{ editId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button>
</div>
</form>
</div>
</div>
@@ -175,10 +249,13 @@ import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'
import { gardensApi, type Garden } from '@/api/gardens'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const store = useGardensStore()
const toast = useToast()
const showForm = ref(false)
const submitting = ref(false)
const editId = ref<number | null>(null)
const photoFile = ref<File | null>(null)
const photoPreview = ref('')
@@ -244,7 +321,16 @@ function onPhotoSelected(event: Event) {
}
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
async function submit() {
if (submitting.value) return
submitting.value = true
const autoLongueur =
form.carre_potager && form.carre_x_cm != null
? Number((form.carre_x_cm / 100).toFixed(2))
@@ -280,19 +366,46 @@ async function submit() {
sol_type: form.sol_type || undefined,
}
let saved: Garden
if (editId.value) {
saved = await store.update(editId.value, payload)
} else {
saved = await store.create(payload)
}
try {
let saved: Garden
if (editId.value) {
saved = await store.update(editId.value, payload)
} else {
saved = await store.create(payload)
}
if (photoFile.value && saved.id) {
await gardensApi.uploadPhoto(saved.id, photoFile.value)
await store.fetchAll()
if (photoFile.value && saved.id) {
try {
await gardensApi.uploadPhoto(saved.id, photoFile.value)
await store.fetchAll()
} catch {
toast.warning('Jardin sauvegardé mais la photo n\'a pas pu être uploadée')
}
}
toast.success(editId.value ? 'Jardin modifié avec succès' : 'Jardin créé avec succès')
closeForm()
} catch {
// L'intercepteur Axios affiche déjà le message d'erreur
} finally {
submitting.value = false
}
closeForm()
}
onMounted(() => store.fetchAll())
async function removeGarden(id: number) {
try {
await store.remove(id)
toast.success('Jardin supprimé')
} catch {
// L'intercepteur Axios affiche le message
}
}
onMounted(async () => {
try {
await store.fetchAll()
} catch {
toast.error('Impossible de charger les jardins')
}
})
</script>

View File

@@ -1,91 +1,129 @@
<template>
<div class="p-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-yellow">🔧 Outils</h1>
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">+ Ajouter</button>
<div class="p-4 max-w-[1800px] mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-yellow tracking-tight">Outils de Jardinage</h1>
<p class="text-text-muted text-xs mt-1">Gérez votre inventaire, notices et vidéos d'utilisation.</p>
</div>
<button @click="openCreate" class="btn-primary !bg-yellow !text-bg flex items-center gap-2">
<span class="text-lg leading-none">+</span> Ajouter
</button>
</div>
<div v-if="toolsStore.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!toolsStore.tools.length" class="text-text-muted text-sm py-4">Aucun outil enregistré.</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-if="toolsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-4">
<div v-for="i in 6" :key="i" class="card-jardin h-32 animate-pulse opacity-20"></div>
</div>
<div v-else-if="!toolsStore.tools.length" class="card-jardin text-center py-16 opacity-50 border-dashed">
<div class="text-4xl mb-4">🔧</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucun outil enregistré.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-4">
<div v-for="t in toolsStore.tools" :key="t.id"
class="bg-bg-soft rounded-lg p-4 border border-bg-hard flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-text font-semibold">{{ t.nom }}</span>
<div class="flex gap-2">
<button @click="startEdit(t)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click="removeTool(t.id!)" class="text-red text-xs hover:underline">Suppr.</button>
class="card-jardin group flex flex-col h-full hover:border-yellow/30 transition-all !p-3">
<div class="flex items-start justify-between mb-2">
<div class="min-w-0 flex-1">
<h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors truncate" :title="t.nom">{{ t.nom }}</h2>
<span v-if="t.categorie" class="badge badge-yellow !text-[8px] mt-0.5">{{ t.categorie }}</span>
</div>
<div class="flex gap-2 shrink-0 ml-2">
<button @click="startEdit(t)" class="text-[10px] text-text-muted hover:text-yellow font-bold uppercase tracking-tighter transition-colors">Édit.</button>
<button @click="removeTool(t.id!)" class="text-[10px] text-text-muted hover:text-red font-bold uppercase tracking-tighter transition-colors">Suppr.</button>
</div>
</div>
<span v-if="t.categorie" class="text-xs text-yellow bg-yellow/10 rounded-full px-2 py-0.5 w-fit">{{ t.categorie }}</span>
<p v-if="t.description" class="text-text-muted text-xs">{{ t.description }}</p>
<p v-if="t.boutique_nom || t.prix_achat != null" class="text-text-muted text-xs">
<span v-if="t.boutique_nom">🛒 {{ t.boutique_nom }}</span>
<span v-if="t.prix_achat != null"> · 💶 {{ t.prix_achat }} </span>
</p>
<a v-if="t.boutique_url" :href="t.boutique_url" target="_blank" rel="noopener noreferrer"
class="text-blue text-xs hover:underline truncate">🔗 Boutique</a>
<a v-if="t.video_url" :href="t.video_url" target="_blank" rel="noopener noreferrer"
class="text-aqua text-xs hover:underline truncate">🎬 Vidéo</a>
<p v-if="t.notice_texte" class="text-text-muted text-xs whitespace-pre-line">{{ t.notice_texte }}</p>
<a v-else-if="t.notice_fichier_url" :href="t.notice_fichier_url" target="_blank" rel="noopener noreferrer"
class="text-aqua text-xs hover:underline truncate">📄 Notice (fichier)</a>
<div v-if="t.photo_url || t.video_url" class="mt-auto pt-2 space-y-2">
<img v-if="t.photo_url" :src="t.photo_url" alt="photo outil"
class="w-full h-28 object-cover rounded border border-bg-hard bg-bg" />
<video v-if="t.video_url" :src="t.video_url" controls muted
class="w-full h-36 object-cover rounded border border-bg-hard bg-bg" />
<p v-if="t.description" class="text-text-muted text-[11px] leading-snug line-clamp-2 mb-3 italic opacity-80">
{{ t.description }}
</p>
<!-- Médias Compacts -->
<div v-if="t.photo_url || t.video_url" class="mb-3 rounded-lg overflow-hidden bg-bg-hard border border-bg-soft/30 relative aspect-video shrink-0">
<img v-if="t.photo_url" :src="t.photo_url" class="w-full h-full object-cover transition-transform group-hover:scale-105" />
<div v-if="t.video_url && !t.photo_url" class="w-full h-full flex items-center justify-center text-aqua opacity-30">
<span class="text-2xl">🎬</span>
</div>
<!-- Overlay vidéo icon si dispo -->
<div v-if="t.video_url && t.photo_url" class="absolute bottom-1 right-1 bg-black/60 rounded px-1 py-0.5 text-[8px] text-aqua font-bold uppercase">Vidéo</div>
</div>
<div class="mt-auto pt-2 border-t border-bg-hard/30">
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2 text-[9px] font-bold text-text-muted truncate">
<span v-if="t.boutique_nom" class="truncate">🛒 {{ t.boutique_nom }}</span>
<span v-if="t.prix_achat != null" class="shrink-0 text-yellow">{{ t.prix_achat }}€</span>
</div>
<div class="flex gap-1">
<a v-if="t.boutique_url" :href="t.boutique_url" target="_blank"
class="p-1 hover:bg-blue/10 rounded transition-colors text-blue" title="Boutique">🔗</a>
<a v-if="t.video_url" :href="t.video_url" target="_blank"
class="p-1 hover:bg-aqua/10 rounded transition-colors text-aqua" title="Vidéo">🎬</a>
</div>
</div>
</div>
</div>
</div>
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2>
<form @submit.prevent="submitTool" class="flex flex-col gap-3">
<input v-model="form.nom" placeholder="Nom de l'outil *" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<select v-model="form.categorie"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow">
<option value="">Catégorie</option>
<option value="beche">Bêche</option>
<option value="fourche">Fourche</option>
<option value="griffe">Griffe/Grelinette</option>
<option value="arrosage">Arrosage</option>
<!-- Modal Formulaire -->
<div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-2xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-bold text-xl">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2>
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl"></button>
</div>
<form @submit.prevent="submitTool" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de l'outil *</label>
<input v-model="form.nom" required placeholder="Grelinette, Sécateur..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none transition-all shadow-inner" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-sm outline-none focus:border-yellow">
<option value="">— Choisir —</option>
<option value="beche">Bêche</option>
<option value="fourche">Fourche</option>
<option value="griffe">Griffe/Grelinette</option>
<option value="arrosage">Arrosage</option>
<option value="taille">Taille</option>
<option value="autre">Autre</option>
</select>
<div class="grid grid-cols-2 gap-3">
<input v-model="form.boutique_nom" placeholder="Nom boutique"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<input v-model.number="form.prix_achat" type="number" min="0" step="0.01" placeholder="Prix achat (€)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
</div>
<input v-model="form.boutique_url" type="url" placeholder="URL boutique (https://...)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<textarea v-model="form.description" placeholder="Description..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-16" />
<div>
<label class="text-text-muted text-xs block mb-1">Photo de l'outil</label>
<input type="file" accept="image/*" @change="onPhotoSelected"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<img v-if="photoPreview" :src="photoPreview" alt="Prévisualisation photo"
class="mt-2 w-full h-28 object-cover rounded border border-bg-hard bg-bg" />
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix (€)</label>
<input v-model.number="form.prix_achat" type="number" step="0.01"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-sm outline-none focus:border-yellow" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Vidéo de l'outil</label>
<input type="file" accept="video/*" @change="onVideoSelected"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<video v-if="videoPreview" :src="videoPreview" controls muted
class="mt-2 w-full h-36 object-cover rounded border border-bg-hard bg-bg" />
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Description</label>
<textarea v-model="form.description" rows="1"
@input="autoResize"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2 text-text text-sm focus:border-yellow outline-none resize-none transition-all overflow-hidden" />
</div>
<textarea v-model="form.notice_texte" placeholder="Notice (texte libre)..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-24" />
<div class="flex gap-2 justify-end">
<button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editId ? 'Enregistrer' : 'Créer' }}
<!-- Upload Photo -->
<div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft">
<label class="text-text-muted text-[9px] font-black uppercase tracking-widest block mb-2">Photo de l'outil</label>
<input type="file" accept="image/*" @change="onPhotoSelected" class="text-[10px] text-text-muted w-full" />
<img v-if="photoPreview" :src="photoPreview" class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div>
<!-- Upload Vidéo -->
<div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft">
<label class="text-text-muted text-[9px] font-black uppercase tracking-widest block mb-2">Vidéo démo</label>
<input type="file" accept="video/*" @change="onVideoSelected" class="text-[10px] text-text-muted w-full" />
<video v-if="videoPreview" :src="videoPreview" controls class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div>
<div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-2">
<button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold" @click="closeForm">Annuler</button>
<button type="submit" class="btn-primary !bg-yellow !text-bg px-8">
{{ editId ? 'Sauvegarder' : 'Enregistrer l\'outil' }}
</button>
</div>
</form>
@@ -159,6 +197,12 @@ function onVideoSelected(event: Event) {
if (file) videoPreview.value = URL.createObjectURL(file)
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
function startEdit(t: Tool) {
editId.value = t.id!
Object.assign(form, {

View File

@@ -1,83 +1,120 @@
<template>
<div class="p-4 max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">📆 Planning</h1>
<!-- Navigateur 4 semaines -->
<div class="flex items-center gap-2">
<button @click="prevPeriod"
class="text-xs text-text-muted border border-bg-hard rounded px-2 py-1 hover:text-text hover:border-text-muted">
Prev
</button>
<button @click="goToday"
class="text-xs text-green border border-green/30 rounded px-2 py-1 hover:bg-green/10">
Today
</button>
<button @click="nextPeriod"
class="text-xs text-text-muted border border-bg-hard rounded px-2 py-1 hover:text-text hover:border-text-muted">
Next
</button>
<div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Planning</h1>
<p class="text-text-muted text-xs mt-1">Visualisez et planifiez vos interventions sur 4 semaines.</p>
</div>
</div>
<div class="text-text text-sm font-medium mb-3">{{ periodLabel }}</div>
<!-- En-tête jours -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div v-for="dayName in dayHeaders" :key="dayName" class="text-center text-xs py-1 rounded text-text-muted">
{{ dayName }}
<!-- Navigateur -->
<div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
<button @click="prevPeriod" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Préc.</button>
<button @click="goToday" class="btn-primary !py-1.5 !px-4 text-xs !rounded-lg">Aujourd'hui</button>
<button @click="nextPeriod" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Suiv.</button>
</div>
</div>
<!-- Grille 4 semaines -->
<div class="grid grid-cols-7 gap-1">
<div v-for="d in periodDays" :key="d.iso"
@click="selectDay(d.iso)"
:class="['min-h-24 rounded-lg p-1 border transition-colors cursor-pointer',
d.isToday ? 'border-green/40 bg-green/5' : 'border-bg-hard bg-bg-soft',
selectedIso === d.iso ? 'ring-1 ring-yellow/60 border-yellow/40' : '']">
<div class="text-[11px] text-text-muted mb-1">
<span :class="d.isToday ? 'text-green font-bold' : ''">{{ d.dayNum }}</span>
<span v-if="d.showMonth" class="ml-1">{{ d.monthShort }}</span>
</div>
<div v-if="todoTasksByDay[d.iso]?.length" class="flex items-center gap-1 flex-wrap mb-1">
<span v-for="(t, i) in todoTasksByDay[d.iso].slice(0, 10)" :key="`${d.iso}-${t.id ?? i}`"
:class="['w-1.5 h-1.5 rounded-full', dotClass(t.priorite)]"></span>
</div>
<div v-for="t in tasksByDay[d.iso] || []" :key="t.id"
:class="['text-xs rounded px-1 py-0.5 mb-0.5 cursor-pointer hover:opacity-80 truncate',
priorityClass(t.priorite)]"
:title="t.titre">
{{ t.titre }}
</div>
<!-- Zone drop-cible (vide) -->
<div v-if="!(tasksByDay[d.iso]?.length)" class="text-text-muted text-xs text-center pt-2 opacity-40"></div>
<div class="flex items-center gap-3">
<div class="text-yellow font-bold text-sm tracking-widest uppercase bg-yellow/5 px-4 py-1 rounded-full border border-yellow/10">
{{ periodLabel }}
</div>
</div>
<!-- Détail jour sélectionné -->
<div class="mt-4 bg-bg-soft rounded-lg p-3 border border-bg-hard">
<div class="text-text text-sm font-semibold">{{ selectedLabel }}</div>
<div class="text-text-muted text-xs mt-0.5">{{ selectedTasks.length }} tâche(s) planifiée(s)</div>
<div v-if="!selectedTasks.length" class="text-text-muted text-xs mt-2">Aucune tâche planifiée ce jour.</div>
<div v-else class="mt-2 space-y-1">
<div v-for="t in selectedTasks" :key="t.id"
class="bg-bg rounded px-2 py-1 border border-bg-hard flex items-center gap-2">
<span :class="['w-2 h-2 rounded-full shrink-0', dotClass(t.priorite)]"></span>
<span class="text-text text-xs flex-1 truncate">{{ t.titre }}</span>
<span :class="['text-[10px] px-1.5 py-0.5 rounded shrink-0', statutClass(t.statut)]">{{ t.statut }}</span>
<!-- Calendrier Grid -->
<div class="space-y-4">
<!-- En-tête jours -->
<div class="grid grid-cols-7 gap-3">
<div v-for="dayName in dayHeaders" :key="dayName"
class="text-center text-[10px] font-black uppercase tracking-[0.2em] text-text-muted/60 pb-2">
{{ dayName }}
</div>
</div>
<!-- Grille 4 semaines -->
<div class="grid grid-cols-7 gap-3">
<div v-for="d in periodDays" :key="d.iso"
@click="selectDay(d.iso)"
:class="['min-h-32 card-jardin !p-2 flex flex-col group relative cursor-pointer border-2 transition-all',
d.isToday ? 'border-green/40 bg-green/5' : 'border-bg-hard/50',
selectedIso === d.iso ? '!border-yellow bg-yellow/5 scale-[1.02] z-10 shadow-xl' : 'hover:border-bg-soft']">
<div class="flex items-center justify-between mb-2">
<span :class="['text-xs font-bold font-mono', d.isToday ? 'text-green' : 'text-text-muted']">{{ d.dayNum }}</span>
<span v-if="d.showMonth" class="text-[9px] font-black uppercase tracking-tighter text-aqua">{{ d.monthShort }}</span>
</div>
<div class="flex-1 space-y-1 overflow-hidden">
<div v-for="t in tasksByDay[d.iso]?.slice(0, 3)" :key="t.id"
:class="['text-[9px] font-bold uppercase tracking-tighter rounded px-1.5 py-0.5 truncate border', priorityBorderClass(t.priorite)]">
{{ t.titre }}
</div>
<div v-if="tasksByDay[d.iso]?.length > 3" class="text-[9px] text-text-muted font-bold text-center pt-1">
+ {{ tasksByDay[d.iso].length - 3 }} autres
</div>
</div>
<!-- Indicateurs points si plein -->
<div v-if="todoTasksByDay[d.iso]?.length" class="absolute bottom-2 right-2 flex gap-0.5">
<span v-for="(t, i) in todoTasksByDay[d.iso].slice(0, 3)" :key="i"
:class="['w-1 h-1 rounded-full', dotClass(t.priorite)]"></span>
</div>
</div>
</div>
</div>
<!-- Tâches sans date -->
<div class="mt-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">Sans date</h2>
<div v-if="!unscheduled.length" class="text-text-muted text-xs pl-2">Toutes les tâches ont une échéance.</div>
<div v-for="t in unscheduled" :key="t.id"
class="bg-bg-soft rounded-lg p-2 mb-1 border border-bg-hard flex items-center gap-2">
<span :class="['text-xs w-2 h-2 rounded-full flex-shrink-0', dotClass(t.priorite)]"></span>
<span class="text-text text-sm flex-1 truncate">{{ t.titre }}</span>
<span :class="['text-xs px-1.5 py-0.5 rounded', statutClass(t.statut)]">{{ t.statut }}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-4">
<!-- Détail jour sélectionné -->
<section class="lg:col-span-2 space-y-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span>
Détails du {{ selectedLabel }}
</h2>
<div v-if="!selectedTasks.length" class="card-jardin text-center py-12 opacity-40 border-dashed">
<p class="text-text-muted text-sm italic">Libre comme l'air ! Aucune tâche pour cette date. 🍃</p>
</div>
<div v-else class="space-y-3">
<div v-for="t in selectedTasks" :key="t.id"
class="card-jardin flex items-center gap-4 group">
<div :class="['w-1.5 h-10 rounded-full shrink-0', dotClass(t.priorite)]"></div>
<div class="flex-1 min-w-0">
<div class="text-text font-bold text-sm">{{ t.titre }}</div>
<div class="flex items-center gap-3 mt-1">
<span :class="['badge !text-[9px]', statutClass(t.statut)]">{{ t.statut?.replace('_', ' ') }}</span>
<span class="text-[10px] text-text-muted uppercase font-bold tracking-tighter opacity-60">Priorité {{ t.priorite }}</span>
</div>
</div>
<div class="text-green text-xl opacity-0 group-hover:opacity-100 transition-all"></div>
</div>
</div>
</section>
<!-- Tâches sans date -->
<section class="space-y-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-orange"></span>
À planifier
</h2>
<div v-if="!unscheduled.length" class="card-jardin text-center py-12 opacity-30 border-dashed">
<p class="text-[10px] font-bold uppercase">Tout est en ordre</p>
</div>
<div class="space-y-2">
<div v-for="t in unscheduled" :key="t.id"
class="card-jardin !p-3 flex flex-col gap-2 hover:border-orange/30 transition-colors">
<div class="flex items-start justify-between gap-2">
<span class="text-text font-bold text-xs flex-1 line-clamp-2">{{ t.titre }}</span>
<div :class="['w-2 h-2 rounded-full shrink-0 mt-1', dotClass(t.priorite)]"></div>
</div>
<div class="flex justify-between items-center">
<span :class="['badge !text-[8px]', statutClass(t.statut)]">{{ t.statut }}</span>
<button class="text-[10px] text-orange font-bold uppercase hover:underline">Planifier</button>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
@@ -185,19 +222,22 @@ function selectDay(iso: string) {
selectedIso.value = iso
}
const priorityClass = (p: string) => ({
haute: 'bg-red/20 text-red',
normale: 'bg-yellow/20 text-yellow',
basse: 'bg-bg-hard text-text-muted',
}[p] || 'bg-bg-hard text-text-muted')
const priorityBorderClass = (p: string) => ({
haute: 'border-red/30 text-red bg-red/5',
normale: 'border-yellow/30 text-yellow bg-yellow/5',
basse: 'border-bg-soft text-text-muted bg-bg-hard/50',
}[p] || 'border-bg-soft text-text-muted')
const dotClass = (p: string) => ({
haute: 'bg-red', normale: 'bg-yellow', basse: 'bg-text-muted',
haute: 'bg-red shadow-[0_0_5px_rgba(251,73,52,0.5)]',
normale: 'bg-yellow shadow-[0_0_5px_rgba(250,189,47,0.5)]',
basse: 'bg-text-muted',
}[p] || 'bg-text-muted')
const statutClass = (s: string) => ({
a_faire: 'bg-blue/20 text-blue', en_cours: 'bg-green/20 text-green',
fait: 'bg-text-muted/20 text-text-muted',
a_faire: 'badge-blue',
en_cours: 'badge-green',
fait: 'badge-text-muted',
}[s] || '')
onMounted(() => store.fetchAll())

View File

@@ -1,18 +1,20 @@
<template>
<div class="p-4 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">🌱 Plantations</h1>
<button @click="showCreate = true"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
+ Nouvelle
<div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Plantations</h1>
<p class="text-text-muted text-xs mt-1">Suivi de vos cultures, de la plantation à la récolte.</p>
</div>
<button @click="showCreate = true" class="btn-primary flex items-center gap-2">
<span class="text-lg leading-none">+</span> Nouvelle
</button>
</div>
<!-- Filtres statut -->
<div class="flex gap-2 mb-4 flex-wrap">
<div class="flex gap-2 mb-4 bg-bg-soft/30 p-1 rounded-full w-fit border border-bg-soft">
<button v-for="s in statuts" :key="s.val" @click="filterStatut = s.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
filterStatut === s.val ? 'bg-blue text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
:class="['px-4 py-1.5 rounded-full text-xs font-bold transition-all',
filterStatut === s.val ? 'bg-yellow text-bg shadow-lg' : 'text-text-muted hover:text-text']">
{{ s.label }}
</button>
</div>
@@ -22,79 +24,93 @@
Aucune plantation enregistrée.
</div>
<div v-for="p in filtered" :key="p.id"
class="bg-bg-soft rounded-xl mb-3 border border-bg-hard overflow-hidden">
<!-- En-tête plantation -->
<div class="p-4 flex items-start gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-text font-semibold">{{ plantName(p.variety_id) }}</span>
<span class="text-text-muted text-xs"> {{ gardenName(p.garden_id) }}</span>
<span :class="['text-xs px-2 py-0.5 rounded-full font-medium', statutClass(p.statut)]">{{ p.statut }}</span>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="p in filtered" :key="p.id"
class="card-jardin group flex flex-col justify-between">
<div>
<div class="flex items-start justify-between mb-3">
<div>
<h2 class="text-text font-bold text-lg group-hover:text-green transition-colors">{{ plantName(p.variety_id) }}</h2>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="badge badge-yellow">{{ gardenName(p.garden_id) }}</span>
<span :class="['badge', statutClass(p.statut)]">{{ p.statut?.replace('_', ' ') }}</span>
<template v-if="p.cell_ids?.length">
<span v-for="cid in p.cell_ids" :key="cid" class="badge bg-orange/20 text-orange">
📍 {{ cellLabelById(p.garden_id, cid) }}
</span>
</template>
<span v-else-if="p.cell_id" class="badge bg-orange/20 text-orange">📍 {{ cellLabelById(p.garden_id, p.cell_id) }}</span>
</div>
</div>
<div class="flex gap-1">
<button @click="startEdit(p)" class="p-1.5 text-text-muted hover:text-yellow transition-colors" title="Modifier"></button>
<button @click="store.remove(p.id!)" class="p-1.5 text-text-muted hover:text-red transition-colors" title="Supprimer"></button>
</div>
</div>
<div class="text-text-muted text-xs mt-1 flex gap-3 flex-wrap">
<span>{{ p.quantite }} plant(s)</span>
<span v-if="p.date_plantation">🌱 {{ fmtDate(p.date_plantation) }}</span>
<span v-if="p.boutique_nom">🛒 {{ p.boutique_nom }}</span>
<span v-if="p.tarif_achat != null">💶 {{ p.tarif_achat }} </span>
<span v-if="p.date_achat">🧾 {{ fmtDate(p.date_achat) }}</span>
<span v-if="p.notes">📝 {{ p.notes }}</span>
<div class="flex flex-wrap gap-y-2 gap-x-4 text-[11px] text-text-muted font-bold uppercase tracking-wider mb-4 opacity-80">
<span class="flex items-center gap-1.5">📦 {{ p.quantite }} plants</span>
<span v-if="p.date_plantation" class="flex items-center gap-1.5">📅 {{ fmtDate(p.date_plantation) }}</span>
<span v-if="p.boutique_nom" class="flex items-center gap-1.5">🛒 {{ p.boutique_nom }}</span>
</div>
<p v-if="p.notes" class="text-text-muted text-xs italic mb-4 line-clamp-2 bg-bg-hard/30 p-2 rounded-lg border-l-2 border-bg-soft">
{{ p.notes }}
</p>
</div>
<div class="flex gap-2 items-center">
<div class="flex gap-2 pt-3 border-t border-bg-hard/50">
<button @click="toggleRecoltes(p.id!)"
:class="['text-xs px-2 py-1 rounded transition-colors',
openRecoltes === p.id ? 'bg-aqua/20 text-aqua' : 'bg-bg-hard text-text-muted hover:text-aqua']">
🍅 Récoltes
:class="['flex-1 btn-outline py-1.5 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-widest',
openRecoltes === p.id ? 'bg-aqua text-bg border-aqua' : 'border-aqua/20 text-aqua hover:bg-aqua/10']">
<span>🍅</span> Récoltes
</button>
<button
class="text-xs px-2 py-1 rounded bg-blue/20 text-blue hover:bg-blue/30 transition-colors"
class="btn-outline py-1.5 px-4 border-blue/20 text-blue hover:bg-blue/10 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-widest"
@click="openTaskFromTemplate(p)"
>
Tâche
<span></span> Tâche
</button>
<button @click="startEdit(p)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click="store.remove(p.id!)" class="text-text-muted hover:text-red text-sm ml-1"></button>
</div>
</div>
<!-- Section récoltes (dépliable) -->
<div v-if="openRecoltes === p.id" class="border-t border-bg-hard px-4 py-3 bg-bg/50">
<div v-if="loadingRecoltes" class="text-text-muted text-xs py-2">Chargement...</div>
<div v-else>
<div v-if="!recoltesList.length" class="text-text-muted text-xs mb-2">Aucune récolte enregistrée.</div>
<div v-for="r in recoltesList" :key="r.id"
class="flex items-center gap-3 text-sm py-1 border-b border-bg-hard last:border-0">
<span class="text-aqua font-mono">{{ r.quantite }} {{ r.unite }}</span>
<span class="text-text-muted text-xs">{{ fmtDate(r.date_recolte) }}</span>
<span v-if="r.notes" class="text-text-muted text-xs flex-1 truncate">{{ r.notes }}</span>
<button @click="deleteRecolte(r.id!, p.id!)" class="text-text-muted hover:text-red text-xs ml-auto"></button>
<!-- Section récoltes (dépliable) -->
<div v-if="openRecoltes === p.id" class="mt-4 animate-fade-in space-y-3 bg-bg-hard/50 p-4 rounded-xl border border-aqua/10">
<div v-if="loadingRecoltes" class="text-center py-4">
<div class="w-6 h-6 border-2 border-aqua/20 border-t-aqua rounded-full animate-spin mx-auto"></div>
</div>
<div v-else>
<div v-if="!recoltesList.length" class="text-text-muted text-[10px] uppercase font-bold text-center py-2">Aucune récolte.</div>
<div v-for="r in recoltesList" :key="r.id"
class="flex items-center gap-3 text-sm py-2 border-b border-bg-hard last:border-0 group/row">
<span class="badge badge-green font-mono">{{ r.quantite }} {{ r.unite }}</span>
<span class="text-text-muted text-xs font-bold">{{ fmtDate(r.date_recolte) }}</span>
<span v-if="r.notes" class="text-text-muted text-xs italic flex-1 truncate">{{ r.notes }}</span>
<button @click="deleteRecolte(r.id!, p.id!)" class="text-text-muted hover:text-red opacity-0 group-hover/row:opacity-100 transition-opacity"></button>
</div>
<!-- Formulaire ajout récolte -->
<form @submit.prevent="addRecolte(p.id!)" class="flex gap-2 mt-3 flex-wrap items-end">
<div>
<label class="text-text-muted text-xs block mb-1">Quantité *</label>
<input v-model.number="rForm.quantite" type="number" step="0.1" min="0" required
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs w-20 focus:border-aqua outline-none" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Unité</label>
<select v-model="rForm.unite"
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none">
<option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date *</label>
<input v-model="rForm.date_recolte" type="date" required
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none" />
</div>
<button type="submit"
class="bg-aqua text-bg px-3 py-1 rounded text-xs font-semibold hover:opacity-90 self-end">
+ Ajouter
</button>
</form>
<!-- Formulaire ajout récolte -->
<form @submit.prevent="addRecolte(p.id!)" class="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-bg-hard">
<div class="col-span-1">
<input v-model.number="rForm.quantite" type="number" step="0.1" placeholder="Qté" required
class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none" />
</div>
<div class="col-span-1">
<select v-model="rForm.unite"
class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none">
<option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option>
</select>
</div>
<div class="col-span-1">
<input v-model="rForm.date_recolte" type="date" required
class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none font-mono" />
</div>
<button type="submit"
class="col-span-3 bg-aqua text-bg py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:opacity-90">
Enregistrer la récolte
</button>
</form>
</div>
</div>
</div>
</div>
@@ -136,8 +152,9 @@
<label class="text-text-muted text-xs block mb-1">Description complémentaire (optionnel)</label>
<textarea
v-model="taskTemplateForm.extra_description"
rows="2"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none"
rows="1"
@input="autoResize"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none transition-all overflow-hidden"
/>
</div>
<div class="flex gap-2 justify-end">
@@ -156,111 +173,203 @@
</div>
<!-- Modal création / édition plantation -->
<div v-if="showCreate" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
<div v-if="showCreate" class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center sm:p-4"
@click.self="closeCreate">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier la plantation' : 'Nouvelle plantation' }}</h2>
<form @submit.prevent="createPlanting" class="flex flex-col gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Jardin *</label>
<select v-model.number="cForm.garden_id" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir un jardin</option>
<option v-for="g in gardensStore.gardens" :key="g.id" :value="g.id">{{ g.nom }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Plante *</label>
<select v-model.number="cForm.variety_id" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir une plante</option>
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
{{ formatPlantLabel(p) }}
</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-bg-hard w-full sm:rounded-xl sm:max-w-2xl border-t sm:border border-bg-soft flex flex-col max-h-[92vh] sm:max-h-[90vh]">
<!-- En-tête fixe -->
<div class="flex items-center justify-between px-5 pt-5 pb-4 border-b border-bg-soft/40 shrink-0">
<h2 class="text-text font-bold text-base">{{ editId ? 'Modifier la plantation' : 'Nouvelle plantation' }}</h2>
<button type="button" @click="closeCreate" class="text-text-muted hover:text-text text-xl leading-none"></button>
</div>
<!-- Corps scrollable -->
<form id="planting-form" @submit.prevent="createPlanting"
class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<!-- Jardin + grille zones (pleine largeur) -->
<div class="space-y-3">
<div>
<label class="text-text-muted text-xs block mb-1">Quantité</label>
<input v-model.number="cForm.quantite" type="number" min="1"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<label class="text-text-muted text-xs block mb-1">Jardin *</label>
<select v-model.number="cForm.garden_id" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir un jardin</option>
<option v-for="g in gardensStore.gardens" :key="g.id" :value="g.id">{{ g.nom }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date plantation</label>
<input v-model="cForm.date_plantation" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<!-- Grille des zones (multi-sélect) -->
<div v-if="cForm.garden_id" class="bg-bg/50 rounded-lg p-3 border border-bg-soft/40">
<div class="flex items-center justify-between mb-2">
<label class="text-text-muted text-xs font-bold uppercase tracking-wider">
Zones
<span class="text-text-muted/40 normal-case font-normal tracking-normal ml-1">(optionnel)</span>
</label>
<button v-if="cForm.cell_ids.length" type="button"
@click="cForm.cell_ids = []"
class="text-[10px] text-red/70 hover:text-red font-bold uppercase tracking-wider">
Effacer tout
</button>
</div>
<div v-if="loadingCells" class="flex justify-center py-3">
<div class="w-5 h-5 border-2 border-green/20 border-t-green rounded-full animate-spin"></div>
</div>
<div v-else class="space-y-2">
<div class="grid gap-1"
:style="{ gridTemplateColumns: `repeat(${selectedGarden?.grille_largeur || 4}, minmax(0, 1fr))` }">
<button
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
type="button"
:disabled="cell.etat === 'non_cultivable' || (cell.id != null && occupiedCellIds.has(cell.id)) || creatingCell"
@click="selectCell(cell)"
:title="cell.etat === 'non_cultivable' ? 'Non cultivable'
: (cell.id != null && occupiedCellIds.has(cell.id)) ? 'Zone occupée'
: (cell.libelle || `Col ${cell.col + 1}, Rg ${cell.row + 1}`)"
:class="[
'rounded py-1 px-0.5 text-[10px] font-mono transition-all border truncate min-h-6 text-center select-none',
cell.id != null && cForm.cell_ids.includes(cell.id)
? 'bg-green text-bg border-green font-bold'
: cell.etat === 'non_cultivable'
? 'bg-red/10 text-red/40 border-red/20 cursor-not-allowed'
: (cell.id != null && occupiedCellIds.has(cell.id))
? 'bg-bg/20 text-text-muted/30 border-bg-soft/20 cursor-not-allowed line-through'
: 'bg-bg border-bg-soft/60 text-text-muted hover:border-green hover:text-green cursor-pointer'
]">
{{ cell.libelle || `${String.fromCharCode(65 + cell.row)}${cell.col + 1}` }}
</button>
</div>
<div v-if="creatingCell" class="text-xs text-text-muted animate-pulse">Création</div>
<div v-else-if="cForm.cell_ids.length" class="flex flex-wrap gap-1 pt-0.5">
<span v-for="cid in cForm.cell_ids" :key="cid"
class="bg-green/20 text-green text-[10px] px-1.5 py-0.5 rounded font-mono">
{{ cellLabelById(cForm.garden_id, cid) }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Statut</label>
<select v-model="cForm.statut"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="prevu">Prévu</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminé</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Nom boutique</label>
<input v-model="cForm.boutique_nom" type="text" placeholder="Ex: Graines Bocquet"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<!-- 2 colonnes sur desktop -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Colonne gauche : infos essentielles -->
<div class="space-y-3">
<div>
<label class="text-text-muted text-xs block mb-1">Plante *</label>
<select v-model.number="cForm.variety_id" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir une plante</option>
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
{{ formatPlantLabel(p) }}
</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Quantité</label>
<input v-model.number="cForm.quantite" type="number" min="1"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date plantation</label>
<input v-model="cForm.date_plantation" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Statut</label>
<select v-model="cForm.statut"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="prevu">Prévu</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminé</option>
</select>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date achat</label>
<input v-model="cForm.date_achat" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<!-- Colonne droite : infos achat + notes -->
<div class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Boutique</label>
<input v-model="cForm.boutique_nom" type="text" placeholder="Ex: Bocquet"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date achat</label>
<input v-model="cForm.date_achat" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Tarif ()</label>
<input v-model.number="cForm.tarif_achat" type="number" min="0" step="0.01" placeholder="0.00"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">URL boutique</label>
<input v-model="cForm.boutique_url" type="url" placeholder="https://..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Notes</label>
<textarea v-model="cForm.notes" rows="3" placeholder="Observations, variété, provenance…"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none" />
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Tarif achat ()</label>
<input v-model.number="cForm.tarif_achat" type="number" min="0" step="0.01" placeholder="0.00"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">URL boutique</label>
<input v-model="cForm.boutique_url" type="url" placeholder="https://..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<textarea v-model="cForm.notes" placeholder="Notes..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none h-16" />
<div class="flex gap-2 justify-end">
<button type="button" @click="closeCreate" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit" class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editId ? 'Enregistrer' : 'Créer' }}
</button>
</div>
</form>
<!-- Pied fixe avec boutons -->
<div class="px-5 py-4 border-t border-bg-soft/40 shrink-0 flex gap-2 justify-end bg-bg-hard rounded-b-xl">
<button type="button" @click="closeCreate"
class="px-4 py-2 text-text-muted hover:text-text text-sm rounded-lg border border-bg-soft/50 hover:border-bg-soft transition-colors">
Annuler
</button>
<button type="submit" form="planting-form"
class="bg-green text-bg px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
:disabled="submitting">
{{ submitting ? 'Enregistrement…' : (editId ? 'Enregistrer' : 'Créer') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { usePlantingsStore } from '@/stores/plantings'
import { useGardensStore } from '@/stores/gardens'
import { usePlantsStore } from '@/stores/plants'
import type { Planting } from '@/api/plantings'
import { gardensApi, type GardenCell } from '@/api/gardens'
import { recoltesApi, type Recolte } from '@/api/recoltes'
import { tasksApi, type Task } from '@/api/tasks'
import { formatPlantLabel } from '@/utils/plants'
import { useToast } from '@/composables/useToast'
const store = usePlantingsStore()
const gardensStore = useGardensStore()
const plantsStore = usePlantsStore()
const toast = useToast()
const showCreate = ref(false)
const editId = ref<number | null>(null)
const filterStatut = ref('')
const submitting = ref(false)
const openRecoltes = ref<number | null>(null)
const recoltesList = ref<Recolte[]>([])
const loadingRecoltes = ref(false)
const templates = ref<Task[]>([])
const showTaskTemplateModal = ref(false)
const taskTarget = ref<Planting | null>(null)
const gardenCells = ref<GardenCell[]>([])
const loadingCells = ref(false)
const creatingCell = ref(false)
const cellsCache = new Map<number, GardenCell[]>()
const statuts = [
{ val: '', label: 'Toutes' },
@@ -274,7 +383,8 @@ const cForm = reactive({
garden_id: 0, variety_id: 0, quantite: 1,
date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined as number | undefined, date_achat: '',
notes: ''
notes: '',
cell_ids: [] as number[],
})
const rForm = reactive({
@@ -291,11 +401,102 @@ const filtered = computed(() =>
filterStatut.value ? store.plantings.filter(p => p.statut === filterStatut.value) : store.plantings
)
// Chargement des zones quand le jardin change dans le formulaire
watch(() => cForm.garden_id, async (newId, oldId) => {
// Réinitialiser les zones seulement si l'utilisateur change de jardin manuellement
if (oldId !== 0) cForm.cell_ids = []
gardenCells.value = []
if (!newId) return
if (cellsCache.has(newId)) {
gardenCells.value = cellsCache.get(newId)!
return
}
loadingCells.value = true
try {
const cells = await gardensApi.cells(newId)
gardenCells.value = cells
cellsCache.set(newId, cells)
} catch { /* silencieux */ }
finally { loadingCells.value = false }
})
const selectedGarden = computed(() =>
gardensStore.gardens.find(g => g.id === cForm.garden_id)
)
// Zones occupées par des plantations actives (hors récolté/échoué et hors édition en cours)
const occupiedCellIds = computed(() => {
const ids = new Set<number>()
for (const p of store.plantings) {
if (p.id === editId.value || p.statut === 'termine' || p.statut === 'echoue') continue
if (p.cell_ids?.length) {
p.cell_ids.forEach(id => ids.add(id))
} else if (p.cell_id) {
ids.add(p.cell_id)
}
}
return ids
})
// Grille virtuelle complète : cases DB + cases virtuelles générées depuis les dimensions
const displayCells = computed(() => {
if (!selectedGarden.value) return []
const { grille_largeur, grille_hauteur } = selectedGarden.value
const map = new Map(gardenCells.value.map(c => [`${c.row}-${c.col}`, c]))
const result: GardenCell[] = []
for (let row = 0; row < grille_hauteur; row++) {
for (let col = 0; col < grille_largeur; col++) {
result.push(map.get(`${row}-${col}`) ?? {
col, row,
libelle: `${String.fromCharCode(65 + row)}${col + 1}`,
etat: 'libre',
})
}
}
return result
})
function plantName(id: number) {
const p = plantsStore.plants.find(x => x.id === id)
return p ? formatPlantLabel(p) : `Plante #${id}`
}
function cellLabelById(gardenId: number, cellId: number): string {
const cells = cellsCache.get(gardenId)
if (!cells) return `Zone #${cellId}`
const cell = cells.find(c => c.id === cellId)
return cell ? (cell.libelle || `${String.fromCharCode(65 + cell.row)}${cell.col + 1}`) : `Zone #${cellId}`
}
async function selectCell(cell: GardenCell) {
if (creatingCell.value) return
if (cell.etat === 'non_cultivable') return
if (cell.id != null && occupiedCellIds.value.has(cell.id)) return
// Case déjà en DB : toggler dans cell_ids
if (cell.id != null) {
const idx = cForm.cell_ids.indexOf(cell.id)
if (idx !== -1) cForm.cell_ids.splice(idx, 1)
else cForm.cell_ids.push(cell.id)
return
}
// Case virtuelle (pas en DB) : la créer d'abord, puis l'ajouter
if (!cForm.garden_id) return
creatingCell.value = true
try {
const created = await gardensApi.createCell(cForm.garden_id, {
col: cell.col, row: cell.row,
libelle: cell.libelle,
etat: 'libre',
garden_id: cForm.garden_id,
})
const updated = [...gardenCells.value, created]
gardenCells.value = updated
cellsCache.set(cForm.garden_id, updated)
cForm.cell_ids.push(created.id!)
} catch { /* silencieux */ }
finally { creatingCell.value = false }
}
function gardenName(id: number) {
return gardensStore.gardens.find(g => g.id === id)?.nom ?? `Jardin #${id}`
}
@@ -320,15 +521,31 @@ async function toggleRecoltes(id: number) {
}
async function addRecolte(plantingId: number) {
const created = await recoltesApi.create(plantingId, { ...rForm })
recoltesList.value.push(created)
Object.assign(rForm, { quantite: 1, unite: 'kg', date_recolte: new Date().toISOString().slice(0, 10) })
try {
const created = await recoltesApi.create(plantingId, { ...rForm })
recoltesList.value.push(created)
Object.assign(rForm, { quantite: 1, unite: 'kg', date_recolte: new Date().toISOString().slice(0, 10) })
toast.success('Récolte enregistrée')
} catch {
// L'intercepteur Axios affiche le message
}
}
async function deleteRecolte(id: number, plantingId: number) {
async function deleteRecolte(id: number, _plantingId: number) {
if (!confirm('Supprimer cette récolte ?')) return
await recoltesApi.delete(id)
recoltesList.value = recoltesList.value.filter(r => r.id !== id)
try {
await recoltesApi.delete(id)
recoltesList.value = recoltesList.value.filter(r => r.id !== id)
toast.success('Récolte supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
function startEdit(p: typeof store.plantings[0]) {
@@ -342,11 +559,21 @@ function startEdit(p: typeof store.plantings[0]) {
tarif_achat: p.tarif_achat,
date_achat: p.date_achat?.slice(0, 10) || '',
notes: p.notes || '',
cell_ids: p.cell_ids?.length ? [...p.cell_ids] : (p.cell_id ? [p.cell_id] : []),
})
showCreate.value = true
}
function closeCreate() { showCreate.value = false; editId.value = null }
function closeCreate() {
showCreate.value = false
editId.value = null
gardenCells.value = []
Object.assign(cForm, {
garden_id: 0, variety_id: 0, quantite: 1, date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined, date_achat: '',
notes: '', cell_ids: [],
})
}
async function loadTemplates() {
templates.value = await tasksApi.list({ statut: 'template' })
@@ -372,38 +599,55 @@ async function createTaskFromTemplate() {
if (!tpl) return
const extra = taskTemplateForm.extra_description.trim()
const description = [tpl.description || '', extra].filter(Boolean).join('\n\n')
await tasksApi.create({
titre: tpl.titre,
description: description || undefined,
garden_id: taskTarget.value.garden_id,
planting_id: taskTarget.value.id,
priorite: tpl.priorite || 'normale',
echeance: taskTemplateForm.echeance || undefined,
recurrence: tpl.recurrence ?? null,
frequence_jours: tpl.frequence_jours ?? null,
statut: 'a_faire',
})
closeTaskTemplateModal()
try {
await tasksApi.create({
titre: tpl.titre,
description: description || undefined,
garden_id: taskTarget.value.garden_id,
planting_id: taskTarget.value.id,
priorite: tpl.priorite || 'normale',
echeance: taskTemplateForm.echeance || undefined,
recurrence: tpl.recurrence ?? null,
frequence_jours: tpl.frequence_jours ?? null,
statut: 'a_faire',
})
toast.success('Tâche créée depuis le template')
closeTaskTemplateModal()
} catch {
// L'intercepteur Axios affiche le message
}
}
async function createPlanting() {
if (editId.value) {
await store.update(editId.value, { ...cForm })
} else {
await store.create({ ...cForm })
if (submitting.value) return
submitting.value = true
try {
const payload = { ...cForm, cell_ids: cForm.cell_ids, cell_id: cForm.cell_ids[0] ?? undefined }
if (editId.value) {
await store.update(editId.value, payload)
toast.success('Plantation modifiée')
} else {
await store.create(payload)
toast.success('Plantation créée')
}
closeCreate()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
closeCreate()
Object.assign(cForm, {
garden_id: 0, variety_id: 0, quantite: 1, date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined, date_achat: '',
notes: '',
})
}
onMounted(() => {
store.fetchAll()
gardensStore.fetchAll()
plantsStore.fetchAll()
loadTemplates()
onMounted(async () => {
try {
await Promise.all([
store.fetchAll(),
gardensStore.fetchAll(),
plantsStore.fetchAll(),
loadTemplates(),
])
} catch {
toast.error('Erreur lors du chargement des données')
}
})
</script>

View File

@@ -1,321 +1,306 @@
<template>
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">🌱 Plantes</h1>
<button @click="showForm = true" class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">+ Ajouter</button>
<div class="p-4 max-w-[1800px] mx-auto space-y-6">
<!-- En-tête -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-yellow tracking-tight">Bibliothèque des Plantes</h1>
<div class="flex gap-2">
<button v-for="cat in categories" :key="cat.val"
@click="selectedCat = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
selectedCat === cat.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ cat.label }}
</button>
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative min-w-[300px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
<input
v-model="searchQuery"
placeholder="Rechercher une plante, variété..."
class="w-full bg-bg-hard border border-bg-soft rounded-full pl-9 pr-4 py-2 text-text text-sm focus:border-yellow transition-all outline-none shadow-inner"
/>
</div>
<button @click="showForm = true" class="btn-primary !bg-yellow !text-bg flex items-center gap-2 rounded-lg py-2 px-4 shadow-lg hover:scale-105 transition-all font-bold">
<span class="text-lg">+</span> Ajouter une plante
</button>
</div>
</div>
<!-- Filtres catégorie -->
<div class="flex gap-2 mb-4 flex-wrap">
<button v-for="cat in categories" :key="cat.val"
@click="selectedCat = cat.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
selectedCat === cat.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
{{ cat.label }}
</button>
<!-- Grille de 5 colonnes -->
<div v-if="plantsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div v-for="i in 10" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div>
<!-- Liste -->
<div v-if="plantsStore.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!filteredPlants.length" class="text-text-muted text-sm py-4">Aucune plante.</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div v-for="p in filteredPlants" :key="p.id"
class="bg-bg-soft rounded-lg border border-bg-hard overflow-hidden">
<!-- En-tête cliquable -->
<div class="p-4 flex items-start justify-between gap-4 cursor-pointer"
@click="toggleDetail(p.id!)">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="text-text font-semibold">{{ p.nom_commun }}</span>
<span v-if="p.variete" class="text-text-muted text-xs"> {{ p.variete }}</span>
<span v-if="p.categorie" :class="['text-xs px-2 py-0.5 rounded-full font-medium', catClass(p.categorie)]">{{ catLabel(p.categorie) }}</span>
</div>
<div class="text-text-muted text-xs flex gap-3 flex-wrap">
<span v-if="p.famille">🌿 {{ p.famille }}</span>
<span v-if="p.espacement_cm"> {{ p.espacement_cm }}cm</span>
<span v-if="p.besoin_eau">💧 {{ p.besoin_eau }}</span>
<span v-if="p.plantation_mois">🌱 Plantation: mois {{ p.plantation_mois }}</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-text-muted text-xs">{{ openId === p.id ? '▲' : '▼' }}</span>
<button @click.stop="startEdit(p)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click.stop="removePlant(p.id!)" class="text-red text-xs hover:underline">Suppr.</button>
</div>
class="card-jardin !p-0 group overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] relative min-h-[160px] cursor-pointer"
:style="{ borderLeftColor: getCatColor(p.categorie || '') }"
@click="openDetails(p)">
<!-- Badge catégorie en haut à gauche -->
<div class="absolute top-2 left-2">
<span :class="['text-[7px] font-black uppercase tracking-[0.2em] px-2 py-0.5 rounded bg-bg/60 backdrop-blur-sm', catTextClass(p.categorie || '')]">
{{ p.categorie }}
</span>
</div>
<!-- Panneau détail -->
<div v-if="openId === p.id" class="border-t border-bg-hard px-4 pb-4 pt-3">
<!-- Notes -->
<p v-if="p.notes" class="text-text-muted text-sm mb-3 italic">{{ p.notes }}</p>
<div class="p-5 flex-1 flex flex-col justify-center">
<h2 class="text-text font-bold text-2xl leading-tight group-hover:text-yellow transition-colors">{{ p.nom_commun }}</h2>
<p v-if="p.variete" class="text-text-muted text-[10px] font-black uppercase tracking-widest mt-1 opacity-60">{{ p.variete }}</p>
<!-- Galerie photos -->
<div class="mb-2 flex items-center justify-between">
<span class="text-text-muted text-xs font-medium uppercase tracking-wide">Photos</span>
<button @click="openUpload(p)" class="text-green text-xs hover:underline">+ Ajouter une photo</button>
</div>
<div v-if="loadingPhotos" class="text-text-muted text-xs">Chargement...</div>
<div v-else-if="!plantPhotos.length" class="text-text-muted text-xs mb-3">Aucune photo pour cette plante.</div>
<div v-else class="grid grid-cols-4 gap-2 mb-3">
<div v-for="m in plantPhotos" :key="m.id"
class="aspect-square rounded overflow-hidden bg-bg-hard relative group cursor-pointer"
@click="lightbox = m">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover" />
<div v-if="m.identified_common"
class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">
{{ m.identified_common }}
</div>
<button @click.stop="deletePhoto(m)" class="hidden group-hover:flex absolute top-1 right-1 bg-red/80 text-white text-xs rounded px-1"></button>
<div class="mt-4 flex flex-wrap gap-2">
<div v-if="p.plantation_mois" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<span class="text-[10px]">📅</span>
<span class="text-[10px] font-black text-text-muted">P: {{ p.plantation_mois }}</span>
</div>
<div v-if="p.besoin_eau" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<span class="text-[10px] text-blue">💧</span>
<span class="text-[10px] font-black text-text-muted">{{ p.besoin_eau }}</span>
</div>
</div>
<!-- Lier une photo existante de la bibliothèque -->
<button @click="openLinkPhoto(p)" class="text-blue text-xs hover:underline">
🔗 Lier une photo existante de la bibliothèque
</button>
</div>
</div>
</div>
<!-- Modal formulaire création / édition -->
<div v-if="showForm || editPlant" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-4xl border border-bg-soft max-h-[90vh] overflow-y-auto">
<h2 class="text-text font-bold text-lg mb-4">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2>
<form @submit.prevent="submitPlant" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- Modale de Détails (Popup) -->
<div v-if="detailPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailPlant = null">
<div class="bg-bg-hard rounded-3xl w-full max-w-2xl border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- Header de la modale -->
<div class="p-6 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${getCatColor(detailPlant.categorie || '')}` }">
<div>
<label class="text-text-muted text-xs block mb-1">Nom commun *</label>
<input v-model="form.nom_commun" placeholder="Ex: Tomate" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Nom utilisé au jardin pour identifier rapidement la plante.</p>
<span :class="['text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block bg-bg/50', catTextClass(detailPlant.categorie || '')]">
{{ detailPlant.categorie }}
</span>
<h2 class="text-text font-black text-4xl leading-none">{{ detailPlant.nom_commun }}</h2>
<p v-if="detailPlant.variete" class="text-yellow font-bold uppercase tracking-widest text-sm mt-1">{{ detailPlant.variete }}</p>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Nom botanique</label>
<input v-model="form.nom_botanique" placeholder="Ex: Solanum lycopersicum"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Nom scientifique utile pour éviter les ambiguïtés.</p>
<button @click="detailPlant = null" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
</div>
<!-- Corps de la modale -->
<div class="p-6 overflow-y-auto space-y-6">
<!-- Caractéristiques -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Besoin en eau</span>
<div class="flex items-center gap-2 text-blue">
<span>💧</span>
<span class="font-bold capitalize">{{ detailPlant.besoin_eau }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Exposition</span>
<div class="flex items-center gap-2 text-yellow">
<span></span>
<span class="font-bold capitalize">{{ detailPlant.besoin_soleil }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Plantation</span>
<div class="flex items-center gap-2 text-green">
<span>📅</span>
<span class="font-bold">Mois: {{ detailPlant.plantation_mois }}</span>
</div>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Variété</label>
<input v-model="form.variete" placeholder="Ex: Andine Cornue"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Cultivar précis (optionnel).</p>
<!-- Notes -->
<div v-if="detailPlant.notes" class="space-y-2">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Conseils & Notes</h3>
<div class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 leading-relaxed italic text-sm whitespace-pre-line">
{{ detailPlant.notes }}
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Famille botanique</label>
<input v-model="form.famille" placeholder="Ex: Solanacées"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Permet d'organiser la rotation des cultures.</p>
<!-- Galerie Photos -->
<div class="space-y-3">
<div class="flex justify-between items-center">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Photos & Médias</h3>
<button @click="openUpload(detailPlant)" class="text-[10px] font-black text-yellow hover:underline uppercase">+ Ajouter</button>
</div>
<div v-if="loadingPhotos" class="grid grid-cols-4 gap-2 animate-pulse">
<div v-for="i in 4" :key="i" class="aspect-square bg-bg-soft rounded-lg"></div>
</div>
<div v-else-if="plantPhotos.length" class="grid grid-cols-4 gap-2">
<div v-for="m in plantPhotos" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg relative group cursor-pointer border border-bg-soft"
@click="lightbox = m">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover group-hover:scale-110 transition-transform" />
<button @click.stop="deletePhoto(m)" class="absolute top-1 right-1 w-6 h-6 bg-red/80 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"></button>
</div>
</div>
<div v-else class="text-center py-8 bg-bg/20 rounded-2xl border border-dashed border-bg-soft opacity-40">
<span class="text-xs italic">Aucune photo pour le moment</span>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Catégorie</label>
<select v-model="form.categorie"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Catégorie</option>
<option value="potager">Potager</option>
<option value="fleur">Fleur</option>
<option value="arbre">Arbre</option>
<option value="arbuste">Arbuste</option>
<option value="adventice">Adventice (mauvaise herbe)</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Classe principale pour filtrer la bibliothèque de plantes.</p>
</div>
<!-- Footer de la modale -->
<div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
<button @click="startEdit(detailPlant)" class="btn-primary !bg-yellow !text-bg flex-1 py-3 font-black uppercase text-xs tracking-widest">Modifier la fiche</button>
<button @click="removePlant(detailPlant.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-6 py-3 font-black uppercase text-xs tracking-widest">Supprimer</button>
</div>
</div>
</div>
<!-- Modale Formulaire (Ajout/Edition) -->
<div v-if="showForm || editPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-4xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-8 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2>
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
</div>
<form @submit.prevent="submitPlant" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Champs de formulaire identiques -->
<div class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom commun *</label>
<input v-model="form.nom_commun" required class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Variété</label>
<input v-model="form.variete" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option v-for="c in categories.slice(1)" :key="c.val" :value="c.val">{{ c.label.split(' ')[1] }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Famille</label>
<input v-model="form.famille" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type de plante</label>
<select v-model="form.type_plante"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Type</option>
<option value="legume">Légume</option>
<option value="fruit">Fruit</option>
<option value="aromatique">Aromatique</option>
<option value="fleur">Fleur</option>
<option value="adventice">Adventice</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Type d'usage de la plante (récolte, ornement, etc.).</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Eau</label>
<select v-model="form.besoin_eau" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option value="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="élevé">Élevé</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Soleil</label>
<select v-model="form.besoin_soleil" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option value="ombre">Ombre</option>
<option value="mi-ombre">Mi-ombre</option>
<option value="plein soleil">Plein soleil</option>
</select>
</div>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois Plantation (ex: 3,4,5)</label>
<input v-model="form.plantation_mois" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes & Conseils</label>
<textarea v-model="form.notes" rows="1" @input="autoResize" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none overflow-hidden" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Besoin en eau</label>
<select v-model="form.besoin_eau"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Besoin en eau</option>
<option value="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="élevé">Élevé</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Aide à planifier l'arrosage.</p>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Ensoleillement</label>
<select v-model="form.besoin_soleil"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Ensoleillement</option>
<option value="ombre">Ombre</option>
<option value="mi-ombre">Mi-ombre</option>
<option value="plein soleil">Plein soleil</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Exposition lumineuse idéale.</p>
</div>
<div class="lg:col-span-2 flex gap-2">
<input v-model.number="form.espacement_cm" type="number" placeholder="Espacement (cm)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm flex-1 outline-none focus:border-green" />
<input v-model.number="form.temp_min_c" type="number" placeholder="T° min (°C)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm flex-1 outline-none focus:border-green" />
</div>
<p class="lg:col-span-2 text-text-muted text-[11px] -mt-2">Espacement recommandé en cm et température minimale supportée (en °C).</p>
<div>
<label class="text-text-muted text-xs block mb-1">Mois de plantation</label>
<input v-model="form.plantation_mois" placeholder="Ex: 3,4,5"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Liste des mois conseillés, séparés par des virgules.</p>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Mois de récolte</label>
<input v-model="form.recolte_mois" placeholder="Ex: 7,8,9"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Période habituelle de récolte.</p>
</div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Notes</label>
<textarea v-model="form.notes" placeholder="Observations, maladies, astuces..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none h-20" />
<p class="text-text-muted text-[11px] mt-1">Commentaires libres visibles dans le détail de la plante.</p>
</div>
<div class="lg:col-span-2 flex gap-2 justify-end">
<button type="button" @click="closeForm"
class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editPlant ? 'Enregistrer' : 'Créer' }}
<div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-4">
<button type="button" @click="closeForm" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl !bg-yellow !text-bg">
{{ editPlant ? 'Sauvegarder' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal upload photo pour une plante -->
<div v-if="uploadTarget" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="uploadTarget = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft">
<h3 class="text-text font-bold mb-4">Photo pour "{{ formatPlantLabel(uploadTarget) }}"</h3>
<label class="block border-2 border-dashed border-bg-soft rounded-lg p-6 text-center cursor-pointer hover:border-green transition-colors">
<input type="file" accept="image/*" class="hidden" @change="uploadPhoto" />
<div class="text-text-muted text-sm">📷 Choisir une image</div>
</label>
<button @click="uploadTarget = null" class="mt-3 w-full text-text-muted hover:text-text text-sm">Annuler</button>
</div>
<!-- Lightbox Photo -->
<div v-if="lightbox" class="fixed inset-0 bg-black/95 z-[100] flex items-center justify-center p-4" @click="lightbox = null">
<img :src="lightbox.url" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl animate-fade-in" />
<button class="absolute top-6 right-6 text-white text-4xl hover:text-yellow"></button>
</div>
<!-- Modal lier photo existante -->
<div v-if="linkTarget" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="linkTarget = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-2xl border border-bg-soft max-h-[80vh] flex flex-col">
<h3 class="text-text font-bold mb-3">Lier une photo à "{{ formatPlantLabel(linkTarget) }}"</h3>
<p class="text-text-muted text-xs mb-3">Sélectionne une photo de la bibliothèque (non liée à une plante)</p>
<div v-if="!unlinkPhotos.length" class="text-text-muted text-sm py-4 text-center">Aucune photo disponible.</div>
<div v-else class="grid grid-cols-4 gap-2 overflow-y-auto flex-1">
<div v-for="m in unlinkPhotos" :key="m.id"
class="aspect-square rounded overflow-hidden bg-bg-hard relative cursor-pointer group border-2 transition-colors"
:class="selectedLinkPhoto === m.id ? 'border-green' : 'border-transparent'"
@click="selectedLinkPhoto = m.id">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover" />
<div v-if="m.identified_common" class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">{{ m.identified_common }}</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<button @click="linkTarget = null" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button @click="confirmLink" :disabled="!selectedLinkPhoto"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
Lier la photo
</button>
</div>
</div>
</div>
<!-- Lightbox -->
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4" @click.self="lightbox = null">
<div class="max-w-lg w-full">
<img :src="lightbox.url" class="w-full rounded-xl" />
<div v-if="lightbox.identified_species" class="text-center mt-3 text-text-muted text-sm">
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
<div class="italic">{{ lightbox.identified_species }}</div>
<div class="text-xs mt-1">Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% — via {{ lightbox.identified_source }}</div>
</div>
<button class="mt-4 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
</div>
</div>
<!-- Upload Photo Trigger (Invisible) -->
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { usePlantsStore } from '@/stores/plants'
import type { Plant } from '@/api/plants'
import { formatPlantLabel } from '@/utils/plants'
import { useToast } from '@/composables/useToast'
const plantsStore = usePlantsStore()
const toast = useToast()
const showForm = ref(false)
const submitting = ref(false)
const editPlant = ref<Plant | null>(null)
const detailPlant = ref<Plant | null>(null)
const selectedCat = ref('')
const openId = ref<number | null>(null)
const searchQuery = ref('')
const plantPhotos = ref<Media[]>([])
const loadingPhotos = ref(false)
const uploadTarget = ref<Plant | null>(null)
const linkTarget = ref<Plant | null>(null)
const unlinkPhotos = ref<Media[]>([])
const selectedLinkPhoto = ref<number | null>(null)
const lightbox = ref<Media | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadTarget = ref<Plant | null>(null)
interface Media {
id: number; entity_type: string; entity_id: number
url: string; thumbnail_url?: string; titre?: string
identified_species?: string; identified_common?: string
identified_confidence?: number; identified_source?: string
}
const categories = [
{ val: '', label: 'Toutes' },
{ val: 'potager', label: '🥕 Potager' },
{ val: 'fleur', label: '🌸 Fleur' },
{ val: 'arbre', label: '🌳 Arbre' },
{ val: 'arbuste', label: '🌿 Arbuste' },
{ val: 'adventice', label: '🌾 Adventices' },
{ val: '', label: 'TOUTES' },
{ val: 'potager', label: '🥕 POTAGER' },
{ val: 'fleur', label: '🌸 FLEUR' },
{ val: 'arbre', label: '🌳 ARBRE' },
{ val: 'arbuste', label: '🌿 ARBUSTE' },
{ val: 'adventice', label: '🌾 ADVENTICES' },
]
const form = reactive({
nom_commun: '', nom_botanique: '', variete: '', famille: '',
categorie: '', type_plante: '', besoin_eau: '', besoin_soleil: '',
espacement_cm: undefined as number | undefined,
temp_min_c: undefined as number | undefined,
plantation_mois: '', recolte_mois: '', notes: '',
nom_commun: '', variete: '', famille: '',
categorie: 'potager', besoin_eau: 'moyen', besoin_soleil: 'plein soleil',
plantation_mois: '', notes: '',
})
const filteredPlants = computed(() => {
const source = selectedCat.value
? plantsStore.plants.filter(p => p.categorie === selectedCat.value)
: plantsStore.plants
return [...source].sort((a, b) => {
const byName = (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr', { sensitivity: 'base' })
if (byName !== 0) return byName
return (a.variete || '').localeCompare(b.variete || '', 'fr', { sensitivity: 'base' })
})
let source = plantsStore.plants
if (selectedCat.value) source = source.filter(p => p.categorie === selectedCat.value)
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
source = source.filter(p =>
p.nom_commun?.toLowerCase().includes(q) ||
p.variete?.toLowerCase().includes(q)
)
}
return [...source].sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'))
})
const catClass = (cat: string) => ({
potager: 'bg-green/20 text-green',
fleur: 'bg-orange/20 text-orange',
arbre: 'bg-blue/20 text-blue',
arbuste: 'bg-yellow/20 text-yellow',
adventice: 'bg-red/20 text-red',
}[cat] || 'bg-bg text-text-muted')
function getCatColor(cat: string) {
return ({
potager: '#b8bb26', fleur: '#fabd2f', arbre: '#83a598',
arbuste: '#d3869b', adventice: '#fb4934',
} as any)[cat] || '#928374'
}
const catLabel = (cat: string) => ({
potager: '🥕 Potager', fleur: '🌸 Fleur', arbre: '🌳 Arbre',
arbuste: '🌿 Arbuste', adventice: '🌾 Adventice',
}[cat] || cat)
function catTextClass(cat: string) {
return ({
potager: 'text-green', fleur: 'text-yellow', arbre: 'text-blue',
arbuste: 'text-purple', adventice: 'text-red',
} as any)[cat] || 'text-text-muted'
}
async function toggleDetail(id: number) {
if (openId.value === id) { openId.value = null; return }
openId.value = id
await fetchPhotos(id)
async function openDetails(p: Plant) {
detailPlant.value = p
await fetchPhotos(p.id!)
}
async function fetchPhotos(plantId: number) {
@@ -331,86 +316,102 @@ async function fetchPhotos(plantId: number) {
}
function startEdit(p: Plant) {
detailPlant.value = null
editPlant.value = p
Object.assign(form, {
nom_commun: p.nom_commun || '', nom_botanique: (p as any).nom_botanique || '',
variete: p.variete || '', famille: p.famille || '',
categorie: p.categorie || '', type_plante: p.type_plante || '',
besoin_eau: p.besoin_eau || '', besoin_soleil: p.besoin_soleil || '',
espacement_cm: p.espacement_cm, temp_min_c: p.temp_min_c,
plantation_mois: p.plantation_mois || '', recolte_mois: p.recolte_mois || '',
notes: p.notes || '',
nom_commun: p.nom_commun || '', variete: p.variete || '', famille: p.famille || '',
categorie: p.categorie || 'potager', besoin_eau: p.besoin_eau || 'moyen', besoin_soleil: p.besoin_soleil || 'plein soleil',
plantation_mois: p.plantation_mois || '', notes: p.notes || '',
})
showForm.value = true
}
function closeForm() {
showForm.value = false
editPlant.value = null
Object.assign(form, {
nom_commun: '', nom_botanique: '', variete: '', famille: '', categorie: '',
type_plante: '', besoin_eau: '', besoin_soleil: '',
espacement_cm: undefined, temp_min_c: undefined,
plantation_mois: '', recolte_mois: '', notes: '',
})
}
async function submitPlant() {
if (editPlant.value) {
await axios.put(`/api/plants/${editPlant.value.id}`, { ...form })
await plantsStore.fetchAll()
} else {
await plantsStore.create({ ...form })
if (submitting.value) return
submitting.value = true
try {
if (editPlant.value) {
await axios.put(`/api/plants/${editPlant.value.id}`, { ...form })
await plantsStore.fetchAll()
toast.success('Plante modifiée')
} else {
await plantsStore.create({ ...form })
toast.success('Plante créée')
}
closeForm()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
closeForm()
}
async function removePlant(id: number) {
if (confirm('Supprimer cette plante ?')) {
if (!confirm('Supprimer définitivement cette plante ?')) return
try {
await plantsStore.remove(id)
if (openId.value === id) openId.value = null
detailPlant.value = null
toast.success('Plante supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
function openUpload(p: Plant) { uploadTarget.value = p }
function openUpload(p: Plant) {
uploadTarget.value = p
fileInput.value?.click()
}
async function uploadPhoto(e: Event) {
async function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !uploadTarget.value) return
const fd = new FormData()
fd.append('file', file)
const { data: uploaded } = await axios.post('/api/upload', fd)
await axios.post('/api/media', {
entity_type: 'plante', entity_id: uploadTarget.value.id,
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
})
uploadTarget.value = null
if (openId.value) await fetchPhotos(openId.value)
try {
const { data: uploaded } = await axios.post('/api/upload', fd)
await axios.post('/api/media', {
entity_type: 'plante', entity_id: uploadTarget.value.id,
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
})
await fetchPhotos(uploadTarget.value.id!)
toast.success('Photo ajoutée')
} catch {
// L'intercepteur Axios affiche le message
} finally {
uploadTarget.value = null
if (fileInput.value) fileInput.value.value = ''
}
}
async function deletePhoto(m: Media) {
if (!confirm('Supprimer cette photo ?')) return
await axios.delete(`/api/media/${m.id}`)
if (openId.value) await fetchPhotos(openId.value)
try {
await axios.delete(`/api/media/${m.id}`)
if (detailPlant.value) await fetchPhotos(detailPlant.value.id!)
toast.success('Photo supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
async function openLinkPhoto(p: Plant) {
linkTarget.value = p
selectedLinkPhoto.value = null
const { data } = await axios.get<Media[]>('/api/media/all')
// Photos non liées à une plante (bibliothèque ou autres)
unlinkPhotos.value = data.filter(m => m.entity_type !== 'plante')
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
async function confirmLink() {
if (!selectedLinkPhoto.value || !linkTarget.value) return
await axios.patch(`/api/media/${selectedLinkPhoto.value}`, {
entity_type: 'plante', entity_id: linkTarget.value.id,
})
const pid = linkTarget.value.id
linkTarget.value = null
selectedLinkPhoto.value = null
if (openId.value === pid) await fetchPhotos(pid)
}
onMounted(() => plantsStore.fetchAll())
onMounted(async () => {
try {
await plantsStore.fetchAll()
} catch {
toast.error('Impossible de charger les plantes')
}
})
</script>

View File

@@ -1,121 +1,181 @@
<template>
<div class="p-4 max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-green mb-4">Réglages</h1>
<div class="p-4 max-w-[1800px] mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Réglages Système</h1>
<p class="text-text-muted text-xs mt-1">Configurez l'interface, la maintenance et la sécurité de votre application.</p>
</div>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
<h2 class="text-text font-semibold mb-2">Interface</h2>
<p class="text-text-muted text-sm mb-4">Ajustez les tailles d'affichage. Les changements sont appliqués instantanément.</p>
<div class="grid grid-cols-1 gap-4">
<div v-for="s in uiSizeSettings" :key="s.key" class="flex items-center gap-3">
<label class="text-sm text-text w-44 shrink-0">{{ s.label }}</label>
<input
type="range"
:min="s.min" :max="s.max" :step="s.step"
v-model.number="uiSizes[s.key]"
class="flex-1 accent-green"
@input="applyUiSizes"
/>
<span class="text-text-muted text-xs w-12 text-right">{{ uiSizes[s.key] }}{{ s.unit }}</span>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
<!-- Section Interface -->
<section class="card-jardin flex flex-col h-full border-green/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🎨</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Interface Graphique</h2>
<p class="text-[10px] text-text-muted font-bold">Ajustez les échelles visuelles.</p>
</div>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
:disabled="savingUi"
@click="saveUiSettings"
>{{ savingUi ? 'Enregistrement...' : 'Enregistrer' }}</button>
<button
class="text-text-muted text-xs hover:text-text px-2"
@click="resetUiSettings"
>Réinitialiser</button>
<span v-if="uiSavedMsg" class="text-xs text-aqua">{{ uiSavedMsg }}</span>
</div>
</section>
<div class="flex-1 space-y-6">
<div v-for="s in uiSizeSettings" :key="s.key" class="space-y-2">
<div class="flex justify-between items-center">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">{{ s.label }}</label>
<span class="text-xs font-mono text-green">{{ uiSizes[s.key] }}{{ s.unit }}</span>
</div>
<input
type="range"
:min="s.min" :max="s.max" :step="s.step"
v-model.number="uiSizes[s.key]"
class="w-full h-1.5 bg-bg-hard rounded-lg appearance-none cursor-pointer accent-green"
@input="applyUiSizes"
/>
</div>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
<h2 class="text-text font-semibold mb-2">Général</h2>
<p class="text-text-muted text-sm mb-3">Options globales de l'application.</p>
<div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-between">
<button
class="text-[10px] font-black uppercase tracking-widest text-text-muted hover:text-text transition-colors"
@click="resetUiSettings"
>Réinitialiser</button>
<div class="flex items-center gap-3">
<span v-if="uiSavedMsg" class="text-[10px] font-bold text-aqua animate-pulse">{{ uiSavedMsg }}</span>
<button
class="btn-primary !py-2 !px-6 text-xs"
:disabled="savingUi"
@click="saveUiSettings"
>{{ savingUi ? '...' : 'Enregistrer' }}</button>
</div>
</div>
</section>
<label class="inline-flex items-center gap-2 text-sm text-text">
<input v-model="debugMode" type="checkbox" class="accent-green" />
Activer le mode debug (affichage CPU / RAM / disque en header)
</label>
<!-- Section Général / Debug -->
<section class="card-jardin flex flex-col h-full border-yellow/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">⚙️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Général & Debug</h2>
<p class="text-[10px] text-text-muted font-bold">Options globales du système.</p>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
</button>
<span v-if="savedMsg" class="text-xs text-aqua">{{ savedMsg }}</span>
</div>
</section>
<div class="flex-1 space-y-6">
<label class="flex items-start gap-4 cursor-pointer group bg-bg-hard/30 p-4 rounded-2xl border border-bg-soft/50 hover:border-yellow/30 transition-all">
<div class="relative mt-1">
<input v-model="debugMode" type="checkbox" class="sr-only peer" />
<div class="w-10 h-5 bg-bg-hard rounded-full peer peer-checked:bg-yellow transition-colors"></div>
<div class="absolute left-1 top-1 w-3 h-3 bg-text-muted peer-checked:bg-bg peer-checked:translate-x-5 rounded-full transition-all"></div>
</div>
<div class="flex-1">
<div class="text-sm font-bold text-text group-hover:text-yellow transition-colors">Mode Debug Interactif</div>
<p class="text-[10px] text-text-muted mt-1 leading-relaxed italic">Affiche les statistiques vitales (CPU, RAM, Disque) dans la barre de navigation supérieure.</p>
</div>
</label>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
<h2 class="text-text font-semibold mb-2">Maintenance météo</h2>
<p class="text-text-muted text-sm mb-3">Déclenche un rafraîchissement immédiat des jobs météo backend.</p>
<button
class="bg-blue text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
:disabled="refreshingMeteo"
@click="refreshMeteo"
>
{{ refreshingMeteo ? 'Rafraîchissement...' : 'Rafraîchir maintenant' }}
</button>
</section>
<div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-end gap-3">
<span v-if="savedMsg" class="text-[10px] font-bold text-aqua">{{ savedMsg }}</span>
<button
class="btn-primary !bg-yellow !text-bg !py-2 !px-6 text-xs"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? '...' : 'Appliquer' }}
</button>
</div>
</section>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
<h2 class="text-text font-semibold mb-2">Test API backend</h2>
<p class="text-text-muted text-sm mb-2">
Ouvre la documentation interactive de l'API et un test rapide de santé.
</p>
<p class="text-text-muted text-xs mb-3">Base API détectée: <span class="text-text">{{ apiBaseUrl }}</span></p>
<div class="flex flex-wrap items-center gap-2">
<button
class="bg-blue text-bg px-3 py-2 rounded-lg text-xs font-semibold hover:opacity-90"
@click="openApiDocs"
>
Ouvrir Swagger (/docs)
</button>
<button
class="bg-aqua text-bg px-3 py-2 rounded-lg text-xs font-semibold hover:opacity-90"
@click="openApiRedoc"
>
Ouvrir ReDoc (/redoc)
</button>
<button
class="bg-bg border border-bg-hard text-text px-3 py-2 rounded-lg text-xs font-semibold hover:border-text-muted"
@click="openApiHealth"
>
Tester /api/health
</button>
</div>
</section>
<!-- Section Maintenance -->
<section class="card-jardin flex flex-col h-full border-blue/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🌦️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Maintenance Météo</h2>
<p class="text-[10px] text-text-muted font-bold">Synchronisation des données externes.</p>
</div>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4">
<h2 class="text-text font-semibold mb-2">Sauvegarde des données</h2>
<p class="text-text-muted text-sm mb-3">
Exporte un ZIP téléchargeable contenant la base SQLite, les images/vidéos uploadées et les fichiers texte utiles.
</p>
<div class="flex items-center gap-2">
<button
class="bg-aqua text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
:disabled="downloadingBackup"
@click="downloadBackup"
>
{{ downloadingBackup ? 'Préparation du ZIP...' : 'Télécharger la sauvegarde (.zip)' }}
</button>
<span v-if="backupMsg" class="text-xs text-aqua">{{ backupMsg }}</span>
</div>
</section>
<div class="flex-1">
<p class="text-xs text-text-muted leading-relaxed mb-6">
Force le rafraîchissement des prévisions Open-Meteo et des relevés de la station locale WeeWX. Les données sont normalement mises à jour toutes les heures automatiquement.
</p>
<button
class="btn-outline w-full py-4 border-blue/20 text-blue hover:bg-blue/10 flex flex-col items-center gap-2"
:disabled="refreshingMeteo"
@click="refreshMeteo"
>
<span class="text-lg">{{ refreshingMeteo ? '🔄' : '' }}</span>
<span class="text-[10px] font-black uppercase tracking-widest">{{ refreshingMeteo ? 'Rafraîchissement en cours...' : 'Forcer la mise à jour' }}</span>
</button>
</div>
</section>
<!-- Section Sauvegarde -->
<section class="card-jardin flex flex-col h-full border-aqua/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">📦</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Sauvegarde & Export</h2>
<p class="text-[10px] text-text-muted font-bold">Protégez vos données.</p>
</div>
</div>
<div class="flex-1 space-y-6">
<p class="text-xs text-text-muted leading-relaxed">
Génère une archive complète (.zip) incluant votre base de données SQLite et tous les médias (photos/vidéos) uploadés.
</p>
<button
class="btn-primary !bg-aqua !text-bg w-full py-4 flex flex-col items-center gap-2 shadow-lg hover:shadow-aqua/20"
:disabled="downloadingBackup"
@click="downloadBackup"
>
<span class="text-xl">💾</span>
<span class="text-[10px] font-black uppercase tracking-widest">{{ downloadingBackup ? 'Préparation...' : 'Télécharger le Pack Complet' }}</span>
</button>
<div v-if="backupMsg" class="text-[10px] text-center font-bold text-aqua animate-bounce">{{ backupMsg }}</div>
</div>
</section>
<!-- Section API Docs (Largeur double sur XL+) -->
<section class="card-jardin xl:col-span-2 flex flex-col border-bg-soft/50">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🛠️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Outils Développeur & API</h2>
<p class="text-[10px] text-text-muted font-bold">Documentation technique et monitoring.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button @click="openApiDocs" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">📖</span>
<span class="text-[10px] font-bold uppercase">Swagger UI</span>
</button>
<button @click="openApiRedoc" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">📄</span>
<span class="text-[10px] font-bold uppercase">ReDoc Docs</span>
</button>
<button @click="openApiHealth" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">💓</span>
<span class="text-[10px] font-bold uppercase">Santé API</span>
</button>
</div>
<div class="mt-6 p-3 bg-bg-hard/50 rounded-xl border border-bg-soft/30 flex items-center justify-between">
<span class="text-[9px] font-bold text-text-muted uppercase tracking-widest">Base URL API détectée</span>
<span class="text-xs font-mono text-aqua">{{ apiBaseUrl }}</span>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { settingsApi } from '@/api/settings'
import { meteoApi } from '@/api/meteo'
import { UI_SIZE_DEFAULTS, applyUiSizesToRoot } from '@/utils/uiSizeDefaults'
@@ -130,10 +190,12 @@ const apiBaseUrl = detectApiBaseUrl()
// --- UI Size settings ---
const uiSizeSettings = [
{ key: 'ui_font_size', label: 'Taille texte', min: 12, max: 20, step: 1, unit: 'px' },
{ key: 'ui_menu_font_size', label: 'Texte menu latéral', min: 11, max: 18, step: 1, unit: 'px' },
{ key: 'ui_menu_icon_size', label: 'Icônes menu', min: 14, max: 28, step: 1, unit: 'px' },
{ key: 'ui_thumb_size', label: 'Miniatures images/vidéo', min: 60, max: 200, step: 4, unit: 'px' },
{ key: 'ui_font_size', label: 'Corps de texte', min: 12, max: 24, step: 1, unit: 'px' },
{ key: 'ui_menu_font_size', label: 'Texte menu latéral', min: 11, max: 20, step: 1, unit: 'px' },
{ key: 'ui_menu_icon_size', label: 'Icônes menu', min: 14, max: 32, step: 1, unit: 'px' },
{ key: 'ui_thumb_size', label: 'Miniatures médias', min: 60, max: 300, step: 4, unit: 'px' },
{ key: 'ui_weather_icon_size', label: 'Icônes Météo', min: 32, max: 128, step: 4, unit: 'px' },
{ key: 'ui_dashboard_icon_size', label: 'Icônes Dashboard', min: 16, max: 64, step: 2, unit: 'px' },
]
const uiSizes = ref<Record<string, number>>({ ...UI_SIZE_DEFAULTS })
@@ -189,17 +251,9 @@ function openInNewTab(path: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
function openApiDocs() {
openInNewTab('/docs')
}
function openApiRedoc() {
openInNewTab('/redoc')
}
function openApiHealth() {
openInNewTab('/api/health')
}
function openApiDocs() { openInNewTab('/docs') }
function openApiRedoc() { openInNewTab('/redoc') }
function openApiHealth() { openInNewTab('/api/health') }
function toBool(value: unknown): boolean {
if (typeof value === 'boolean') return value
@@ -223,7 +277,7 @@ async function loadSettings() {
}
applyUiSizes()
} catch {
// Laisse la valeur locale si l'API n'est pas disponible.
// Laisse la valeur locale
}
}
@@ -233,7 +287,7 @@ async function saveSettings() {
try {
await settingsApi.update({ debug_mode: debugMode.value ? '1' : '0' })
notifyDebugChanged(debugMode.value)
savedMsg.value = 'Enregistré'
savedMsg.value = 'Pris en compte'
window.setTimeout(() => { savedMsg.value = '' }, 1800)
} finally {
saving.value = false
@@ -262,9 +316,9 @@ async function downloadBackup() {
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
backupMsg.value = 'Téléchargement lancé.'
backupMsg.value = 'ZIP prêt !'
} catch {
backupMsg.value = 'Erreur lors de la sauvegarde.'
backupMsg.value = 'Erreur export.'
} finally {
downloadingBackup.value = false
window.setTimeout(() => { backupMsg.value = '' }, 2200)

View File

@@ -1,51 +1,157 @@
<template>
<div class="p-4 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green"> Tâches</h1>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
@click="openCreateTemplate">+ Nouveau template</button>
<div class="p-4 max-w-6xl mx-auto space-y-8">
<!-- En-tête -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Gestion des Tâches</h1>
<p class="text-text-muted text-xs mt-1">Organisez vos travaux au jardin avec des listes et des modèles.</p>
</div>
<button class="btn-primary flex items-center gap-2" @click="openCreateTemplate">
<span class="text-lg leading-none">+</span> Template
</button>
</div>
<div v-for="[groupe, label] in groupes" :key="groupe" class="mb-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2>
<div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2 mb-2"></div>
<div v-for="t in byStatut(groupe)" :key="t.id"
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard">
<span :class="{
'text-red': t.priorite === 'haute',
'text-yellow': t.priorite === 'normale',
'text-text-muted': t.priorite === 'basse'
}"></span>
<div class="flex-1 min-w-0">
<div class="text-text text-sm">{{ t.titre }}</div>
<div v-if="t.description" class="text-text-muted text-xs">{{ t.description }}</div>
<div v-if="t.echeance && t.statut !== 'template'" class="text-text-muted text-xs">📅 {{ fmtDate(t.echeance) }}</div>
<div v-if="t.frequence_jours != null && t.frequence_jours > 0" class="text-text-muted text-xs">
🔁 Tous les {{ t.frequence_jours }} jours
<!-- Section "Créer rapidement" -->
<section class="bg-bg-soft/20 rounded-xl border border-bg-soft overflow-hidden">
<button
class="w-full px-5 py-3 flex items-center justify-between text-left hover:bg-bg-soft/30 transition-colors"
@click="showQuickSection = !showQuickSection"
>
<span class="text-text font-bold text-sm flex items-center gap-2">
<span class="text-yellow"></span> Créer rapidement
</span>
<span class="text-text-muted text-xs flex items-center gap-2">
<span>{{ totalTemplates }} template{{ totalTemplates > 1 ? 's' : '' }} disponible{{ totalTemplates > 1 ? 's' : '' }}</span>
<span :class="['transition-transform inline-block', showQuickSection ? 'rotate-180' : '']"></span>
</span>
</button>
<div v-show="showQuickSection" class="border-t border-bg-soft divide-y divide-bg-soft/50">
<!-- Mes templates personnalisés -->
<div v-if="byStatut('template').length" class="p-4 space-y-3">
<h3 class="text-text-muted text-[10px] font-bold uppercase tracking-widest">Mes templates</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="t in byStatut('template')" :key="t.id"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-all hover:scale-[1.02]"
:class="priorityChipClass(t.priorite)"
@click="openSchedule(t)"
>
<span>{{ priorityIcon(t.priorite) }}</span>
<span>{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="text-[10px] opacity-70">🔁 {{ t.frequence_jours }}j</span>
<span class="ml-1 opacity-50 text-[10px]"> programmer</span>
</button>
</div>
<div v-if="t.planting_id && t.statut !== 'template'" class="text-text-muted text-xs">🌱 Plantation #{{ t.planting_id }}</div>
</div>
<div class="flex gap-1 items-center shrink-0">
<button v-if="t.statut === 'a_faire'" class="text-xs text-blue hover:underline"
@click="store.updateStatut(t.id!, 'en_cours')"> En cours</button>
<button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline"
@click="store.updateStatut(t.id!, 'fait')"> Fait</button>
<button
v-if="t.statut === 'template'"
@click="startEdit(t)"
class="text-xs text-yellow hover:underline ml-2"
>
Édit.
</button>
<button class="text-xs text-text-muted hover:text-red ml-1" @click="store.remove(t.id!)"></button>
<!-- Tâches courantes prédéfinies -->
<div class="p-4 space-y-3">
<h3 class="text-text-muted text-[10px] font-bold uppercase tracking-widest">Tâches courantes</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="qt in quickTemplatesFiltered" :key="qt.titre"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-bg-soft bg-bg text-text-muted text-sm hover:text-text hover:border-text-muted transition-all hover:scale-[1.02]"
@click="openScheduleQuick(qt)"
>
<span>{{ qt.icone }}</span>
<span>{{ qt.titre }}</span>
</button>
</div>
</div>
</div>
</section>
<!-- Kanban : À faire / En cours / Terminé -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div v-for="[groupe, label] in listGroupes" :key="groupe" class="space-y-3">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span :class="['w-2 h-2 rounded-full', groupeColor(groupe)]"></span>
{{ label }}
<span class="ml-auto bg-bg-soft px-2 py-0.5 rounded text-[10px]">{{ byStatut(groupe).length }}</span>
</h2>
<div v-if="!byStatut(groupe).length" class="card-jardin text-center py-8 opacity-30 border-dashed">
<p class="text-text-muted text-xs uppercase tracking-widest font-bold">Aucune tâche</p>
</div>
<div v-for="t in byStatut(groupe)" :key="t.id"
class="card-jardin flex items-center gap-4 group relative overflow-hidden">
<!-- Barre priorité -->
<div :class="[
'absolute left-0 top-0 bottom-0 w-1',
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
]"></div>
<div class="flex-1 min-w-0 pl-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-text font-bold text-sm">{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="badge badge-aqua !text-[8px]">🔁 {{ freqLabel(t.frequence_jours) }}</span>
</div>
<div v-if="t.description" class="text-text-muted text-xs line-clamp-1 mb-1 italic">{{ t.description }}</div>
<div class="flex flex-wrap gap-3 text-[10px] font-bold uppercase tracking-tighter text-text-muted opacity-70">
<span v-if="t.echeance" class="flex items-center gap-1">📅 {{ fmtDate(t.echeance) }}</span>
<span v-if="t.planting_id" class="flex items-center gap-1">🌱 {{ plantingShortLabel(t.planting_id) }}</span>
</div>
</div>
<div class="flex gap-2 items-center shrink-0">
<button v-if="t.statut === 'a_faire'" class="btn-outline !py-1 !px-2 text-blue border-blue/20 hover:bg-blue/10 text-[10px] font-bold uppercase"
@click="updateStatut(t.id!, 'en_cours')">Démarrer</button>
<button v-if="t.statut === 'en_cours'" class="btn-outline !py-1 !px-2 text-green border-green/20 hover:bg-green/10 text-[10px] font-bold uppercase"
@click="updateStatut(t.id!, 'fait')">Terminer</button>
<button @click="removeTask(t.id!)"
class="p-1.5 text-text-muted hover:text-red transition-colors opacity-0 group-hover:opacity-100"></button>
</div>
</div>
</div>
</div>
<!-- Modal création / édition -->
<!-- Bibliothèque de templates -->
<section v-if="byStatut('template').length" class="space-y-3">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Bibliothèque de templates
<span class="ml-auto bg-bg-soft px-2 py-0.5 rounded text-[10px]">{{ byStatut('template').length }}</span>
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<div v-for="t in byStatut('template')" :key="t.id"
class="card-jardin flex items-center gap-3 group relative overflow-hidden">
<div :class="[
'absolute left-0 top-0 bottom-0 w-1',
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
]"></div>
<div class="flex-1 min-w-0 pl-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-text font-bold text-sm">{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="badge badge-aqua !text-[8px]">🔁 {{ freqLabel(t.frequence_jours) }}</span>
</div>
<div v-if="t.description" class="text-text-muted text-xs line-clamp-1 italic">{{ t.description }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
class="btn-outline !py-1 !px-2 text-aqua border-aqua/20 hover:bg-aqua/10 text-[10px] font-bold uppercase"
@click="openSchedule(t)"
>Programmer</button>
<button @click="startEdit(t)"
class="p-1.5 text-text-muted hover:text-yellow transition-colors opacity-0 group-hover:opacity-100"></button>
<button @click="removeTask(t.id!)"
class="p-1.5 text-text-muted hover:text-red transition-colors opacity-0 group-hover:opacity-100"></button>
</div>
</div>
</div>
</section>
<!-- Modal création / édition template -->
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier la tâche' : 'Nouveau template' }}</h2>
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier le template' : 'Nouveau template' }}</h2>
<form @submit.prevent="submit" class="grid gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Titre *</label>
@@ -54,93 +160,328 @@
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Description</label>
<textarea v-model="form.description" rows="2"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" />
<textarea v-model="form.description" rows="1"
@input="autoResize"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none transition-all overflow-hidden" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Priorité</label>
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="basse">Basse</option>
<option value="normale">Normale</option>
<option value="haute">Haute</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type</label>
<input value="Template" readonly
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text-muted text-sm" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Priorité</label>
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="basse">Basse</option>
<option value="normale">Normale</option>
<option value="haute">Haute</option>
</select>
</div>
<div class="bg-bg rounded border border-bg-hard p-3">
<label class="inline-flex items-center gap-2 text-sm text-text">
<label class="inline-flex items-center gap-2 text-sm text-text cursor-pointer">
<input v-model="form.repetition" type="checkbox" class="accent-green" />
Répétition
Répétition périodique
</label>
<p class="text-text-muted text-[11px] mt-1">Fréquence proposée quand la tâche est ajoutée depuis une plantation.</p>
<p class="text-text-muted text-[11px] mt-1">Fréquence proposée quand la tâche est programmée depuis un template.</p>
</div>
<div v-if="form.repetition">
<label class="text-text-muted text-xs block mb-1">Fréquence (jours)</label>
<input
v-model.number="form.frequence_jours"
type="number"
min="1"
step="1"
required
placeholder="Ex: 7"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none"
/>
<div v-if="form.repetition" class="flex gap-2 items-center">
<input v-model.number="form.freq_nb" type="number" min="1" max="99" required placeholder="1"
class="w-20 bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none font-mono" />
<select v-model="form.freq_unite" class="flex-1 bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="jours">Jour(s)</option>
<option value="semaines">Semaine(s)</option>
<option value="mois">Mois</option>
</select>
<span class="text-text-muted text-xs whitespace-nowrap">= {{ formFreqEnJours }} j</span>
</div>
<div class="flex gap-2 mt-2">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">
{{ editId ? 'Enregistrer' : 'Créer le template' }}
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold" :disabled="submitting">
{{ submitting ? 'Enregistrement…' : (editId ? 'Enregistrer' : 'Créer le template') }}
</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button>
</div>
</form>
</div>
</div>
<!-- Modal "Programmer une tâche" -->
<div v-if="showScheduleModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeSchedule">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft max-h-[90vh] overflow-y-auto">
<h2 class="text-text font-bold text-lg mb-1">Programmer une tâche</h2>
<p class="text-text-muted text-xs mb-5 italic">
Crée une tâche <span class="text-blue font-bold">À faire</span> depuis ce template.
</p>
<form @submit.prevent="createScheduled" class="grid gap-3">
<!-- Titre -->
<div>
<label class="text-text-muted text-xs block mb-1">Titre *</label>
<input v-model="scheduleForm.titre" required
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<!-- Date de démarrage -->
<div>
<label class="text-text-muted text-xs block mb-1">
Date de démarrage
<span class="opacity-50">(par défaut : aujourd'hui)</span>
</label>
<input v-model="scheduleForm.date_debut" type="date"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<!-- Priorité -->
<div>
<label class="text-text-muted text-xs block mb-1">Priorité</label>
<select v-model="scheduleForm.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="basse">⚪ Basse</option>
<option value="normale">🟡 Normale</option>
<option value="haute">🔴 Haute</option>
</select>
</div>
<!-- Plantation liée -->
<div>
<label class="text-text-muted text-xs block mb-1">
Plantation liée
<span class="opacity-50">(optionnel)</span>
</label>
<select v-model="scheduleForm.planting_id"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none">
<option :value="null">— Aucune plantation —</option>
<optgroup v-for="g in plantingsByGarden" :key="g.gardenId" :label="g.gardenName">
<option v-for="p in g.plantings" :key="p.id" :value="p.id">
{{ plantingLabel(p) }}
</option>
</optgroup>
</select>
</div>
<!-- Répétition -->
<div class="bg-bg rounded border border-bg-hard p-3 space-y-2">
<label class="inline-flex items-center gap-2 text-sm text-text cursor-pointer">
<input v-model="scheduleForm.repetition" type="checkbox" class="accent-green" />
Répétition périodique
</label>
<div v-if="scheduleForm.repetition" class="flex gap-2 items-center pt-1">
<span class="text-text-muted text-xs shrink-0">Tous les</span>
<input v-model.number="scheduleForm.freq_nb" type="number" min="1" max="99"
class="w-16 bg-bg-soft border border-bg-soft rounded px-2 py-1.5 text-text text-sm focus:border-green outline-none font-mono text-center" />
<select v-model="scheduleForm.freq_unite"
class="flex-1 bg-bg-soft border border-bg-soft rounded px-2 py-1.5 text-text text-sm focus:border-green outline-none">
<option value="jours">Jour(s)</option>
<option value="semaines">Semaine(s)</option>
<option value="mois">Mois</option>
</select>
</div>
<p v-if="scheduleForm.repetition" class="text-text-muted text-[11px]">
→ Récurrence de {{ scheduleFreqEnJours }} jour{{ scheduleFreqEnJours > 1 ? 's' : '' }}
</p>
</div>
<!-- Notes -->
<div>
<label class="text-text-muted text-xs block mb-1">Notes <span class="opacity-50">(optionnel)</span></label>
<textarea v-model="scheduleForm.notes" rows="2" placeholder="Précisions sur cette occurrence…"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" />
</div>
<div class="flex gap-2 mt-1">
<button type="submit" class="bg-blue text-bg px-4 py-2 rounded text-sm font-semibold flex-1" :disabled="submitting">
{{ submitting ? 'Création' : 'Créer la tâche' }}
</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeSchedule">Annuler</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { usePlantingsStore } from '@/stores/plantings'
import { usePlantsStore } from '@/stores/plants'
import { useGardensStore } from '@/stores/gardens'
import type { Task } from '@/api/tasks'
import type { Planting } from '@/api/plantings'
import { useToast } from '@/composables/useToast'
const store = useTasksStore()
const showForm = ref(false)
const editId = ref<number | null>(null)
// ── Types ──────────────────────────────────────────────────────────────────────
interface QuickTemplate {
titre: string
icone: string
priorite: string
description?: string
}
// ── Stores & composables ───────────────────────────────────────────────────────
const store = useTasksStore()
const plantingsStore = usePlantingsStore()
const plantsStore = usePlantsStore()
const gardensStore = useGardensStore()
const toast = useToast()
// ── État UI ────────────────────────────────────────────────────────────────────
const showForm = ref(false)
const showScheduleModal = ref(false)
const showQuickSection = ref(true)
const editId = ref<number | null>(null)
const submitting = ref(false)
// ── Formulaire template (création / édition) ───────────────────────────────────
const form = reactive({
titre: '',
description: '',
priorite: 'normale',
statut: 'template',
repetition: false,
frequence_jours: undefined as number | undefined,
freq_nb: 1 as number,
freq_unite: 'semaines' as 'jours' | 'semaines' | 'mois',
})
const groupes: [string, string][] = [
const formFreqEnJours = computed(() => {
const n = form.freq_nb || 1
if (form.freq_unite === 'semaines') return n * 7
if (form.freq_unite === 'mois') return n * 30
return n
})
// ── Formulaire "programmer" (instancier un template) ──────────────────────────
const scheduleForm = reactive({
titre: '',
date_debut: today(),
notes: '',
priorite: 'normale',
planting_id: null as number | null,
repetition: false,
freq_nb: 1 as number,
freq_unite: 'semaines' as 'jours' | 'semaines' | 'mois',
})
const scheduleFreqEnJours = computed(() => {
const n = scheduleForm.freq_nb || 1
if (scheduleForm.freq_unite === 'semaines') return n * 7
if (scheduleForm.freq_unite === 'mois') return n * 30
return n
})
// ── Templates prédéfinis jardinage ─────────────────────────────────────────────
const QUICK_TEMPLATES: QuickTemplate[] = [
{ titre: 'Arrosage', icone: '💧', priorite: 'normale' },
{ titre: 'Semis en intérieur', icone: '🌱', priorite: 'normale' },
{ titre: 'Semis en pleine terre', icone: '🌾', priorite: 'normale' },
{ titre: 'Repiquage / Transplantation',icone: '🪴', priorite: 'normale' },
{ titre: 'Récolte', icone: '🥕', priorite: 'normale' },
{ titre: 'Taille / Ébourgeonnage', icone: '', priorite: 'normale' },
{ titre: 'Désherbage', icone: '🌿', priorite: 'basse' },
{ titre: 'Fertilisation / Amendement', icone: '💊', priorite: 'normale' },
{ titre: 'Traitement phytosanitaire', icone: '🧪', priorite: 'haute' },
{ titre: 'Observation / Relevé', icone: '👁', priorite: 'basse' },
{ titre: 'Paillage', icone: '🍂', priorite: 'basse' },
{ titre: 'Compostage', icone: '', priorite: 'basse' },
{ titre: 'Buttage', icone: '', priorite: 'normale' },
{ titre: 'Protection gel / Voile', icone: '🌡', priorite: 'haute' },
{ titre: 'Tuteurage', icone: '🪵', priorite: 'normale' },
{ titre: 'Éclaircissage', icone: '🌞', priorite: 'normale' },
]
const quickTemplatesFiltered = computed(() => {
const existing = new Set(byStatut('template').map(t => t.titre.toLowerCase().trim()))
return QUICK_TEMPLATES.filter(qt => !existing.has(qt.titre.toLowerCase().trim()))
})
const totalTemplates = computed(
() => byStatut('template').length + quickTemplatesFiltered.value.length
)
// ── Plantations groupées par jardin pour le <optgroup> ─────────────────────────
const plantingsByGarden = computed(() => {
const gardens = gardensStore.gardens
const plantings = plantingsStore.plantings.filter(p => p.statut !== 'termine' && p.statut !== 'echoue')
const groups: { gardenId: number; gardenName: string; plantings: Planting[] }[] = []
for (const g of gardens) {
const gPlantings = plantings.filter(p => p.garden_id === g.id)
if (gPlantings.length) {
groups.push({ gardenId: g.id!, gardenName: g.nom, plantings: gPlantings })
}
}
// Plantations sans jardin reconnu
const knownGardenIds = new Set(gardens.map(g => g.id))
const orphans = plantings.filter(p => !knownGardenIds.has(p.garden_id))
if (orphans.length) {
groups.push({ gardenId: 0, gardenName: 'Autres', plantings: orphans })
}
return groups
})
function plantingLabel(p: Planting): string {
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id)
const nom = plant
? [plant.nom_commun, plant.variete].filter(Boolean).join(' ')
: `Variété #${p.variety_id}`
const date = p.date_plantation ? ` (${fmtDate(p.date_plantation)})` : ''
return `${nom}${date}`
}
function plantingShortLabel(id: number): string {
const p = plantingsStore.plantings.find(x => x.id === id)
if (!p) return `#${id}`
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id)
return plant?.nom_commun ?? `#${id}`
}
// ── Groupes Kanban ─────────────────────────────────────────────────────────────
const listGroupes: [string, string][] = [
['a_faire', 'À faire'],
['en_cours', 'En cours'],
['fait', 'Terminé'],
['template', 'Templates'],
]
function groupeColor(g: string) {
const map: Record<string, string> = { a_faire: 'bg-blue', en_cours: 'bg-yellow', fait: 'bg-green' }
return map[g] ?? 'bg-bg-hard'
}
const byStatut = (s: string) => store.tasks.filter(t => t.statut === s)
// ── Helpers ────────────────────────────────────────────────────────────────────
function today() {
return new Date().toISOString().slice(0, 10)
}
function fmtDate(s: string) {
return new Date(s + 'T12:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
function freqLabel(jours: number): string {
if (jours % 30 === 0 && jours >= 30) return `${jours / 30}mois`
if (jours % 7 === 0 && jours >= 7) return `${jours / 7}sem`
return `${jours}j`
}
function priorityIcon(p: string) {
return { haute: '🔴', normale: '🟡', basse: '' }[p] ?? ''
}
function priorityChipClass(p: string) {
const map: Record<string, string> = {
haute: 'border-red/30 bg-red/10 text-red hover:bg-red/20',
normale: 'border-yellow/30 bg-yellow/10 text-yellow hover:bg-yellow/20',
basse: 'border-bg-soft bg-bg text-text-muted hover:bg-bg-soft',
}
return map[p] ?? map.basse
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
// ── Gestion template (form création/édition) ───────────────────────────────────
function resetForm() {
Object.assign(form, {
titre: '',
description: '',
priorite: 'normale',
statut: 'template',
repetition: false,
frequence_jours: undefined,
titre: '', description: '', priorite: 'normale',
repetition: false, freq_nb: 1, freq_unite: 'semaines',
})
}
@@ -152,13 +493,19 @@ function openCreateTemplate() {
function startEdit(t: Task) {
editId.value = t.id!
const jours = t.frequence_jours ?? 0
let freq_nb = jours
let freq_unite: 'jours' | 'semaines' | 'mois' = 'jours'
if (jours >= 30 && jours % 30 === 0) { freq_nb = jours / 30; freq_unite = 'mois' }
else if (jours >= 7 && jours % 7 === 0) { freq_nb = jours / 7; freq_unite = 'semaines' }
Object.assign(form, {
titre: t.titre,
description: t.description || '',
priorite: t.priorite,
statut: t.statut || 'template',
repetition: Boolean(t.recurrence || t.frequence_jours),
frequence_jours: t.frequence_jours ?? undefined,
freq_nb: freq_nb || 1,
freq_unite,
})
showForm.value = true
}
@@ -168,25 +515,130 @@ function closeForm() {
editId.value = null
}
onMounted(() => store.fetchAll())
async function submit() {
if (submitting.value) return
submitting.value = true
const freqJours = form.repetition ? formFreqEnJours.value : null
const payload = {
titre: form.titre,
description: form.description,
description: form.description || undefined,
priorite: form.priorite,
statut: 'template',
recurrence: form.repetition ? 'jours' : null,
frequence_jours: form.repetition ? (form.frequence_jours ?? 7) : null,
echeance: undefined,
planting_id: undefined,
frequence_jours: freqJours,
}
if (editId.value) {
await store.update(editId.value, payload)
} else {
await store.create(payload)
try {
if (editId.value) {
await store.update(editId.value, payload)
toast.success('Template modifié')
} else {
await store.create(payload)
toast.success('Template créé')
}
closeForm()
resetForm()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
closeForm()
resetForm()
}
// ── Programmer une tâche depuis un template ────────────────────────────────────
function resetScheduleForm() {
Object.assign(scheduleForm, {
titre: '',
date_debut: today(),
notes: '',
priorite: 'normale',
planting_id: null,
repetition: false,
freq_nb: 1,
freq_unite: 'semaines',
})
}
function openSchedule(t: Task) {
resetScheduleForm()
const jours = t.frequence_jours ?? 0
let freq_nb = jours
let freq_unite: 'jours' | 'semaines' | 'mois' = 'jours'
if (jours >= 30 && jours % 30 === 0) { freq_nb = jours / 30; freq_unite = 'mois' }
else if (jours >= 7 && jours % 7 === 0) { freq_nb = jours / 7; freq_unite = 'semaines' }
Object.assign(scheduleForm, {
titre: t.titre,
notes: t.description || '',
priorite: t.priorite,
repetition: Boolean(t.frequence_jours),
freq_nb: freq_nb || 1,
freq_unite,
})
showScheduleModal.value = true
}
function openScheduleQuick(qt: QuickTemplate) {
resetScheduleForm()
Object.assign(scheduleForm, {
titre: `${qt.icone} ${qt.titre}`,
priorite: qt.priorite,
})
showScheduleModal.value = true
}
function closeSchedule() {
showScheduleModal.value = false
}
async function createScheduled() {
if (submitting.value) return
submitting.value = true
const freqJours = scheduleForm.repetition ? scheduleFreqEnJours.value : null
try {
await store.create({
titre: scheduleForm.titre,
description: scheduleForm.notes || undefined,
priorite: scheduleForm.priorite,
statut: 'a_faire',
echeance: scheduleForm.date_debut || today(),
planting_id: scheduleForm.planting_id ?? undefined,
recurrence: scheduleForm.repetition ? 'jours' : null,
frequence_jours: freqJours,
})
toast.success(`"${scheduleForm.titre}" ajoutée aux tâches à faire`)
closeSchedule()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
}
// ── Actions Kanban ─────────────────────────────────────────────────────────────
async function updateStatut(id: number, statut: string) {
try {
await store.updateStatut(id, statut)
} catch { /* L'intercepteur Axios affiche le message */ }
}
async function removeTask(id: number) {
try {
await store.remove(id)
toast.success('Tâche supprimée')
} catch { /* L'intercepteur Axios affiche le message */ }
}
// ── Initialisation ─────────────────────────────────────────────────────────────
onMounted(async () => {
try {
await Promise.all([
store.fetchAll(),
plantingsStore.fetchAll(),
plantsStore.fetchAll(),
gardensStore.fetchAll(),
])
} catch {
toast.error('Impossible de charger les données')
}
})
</script>

View File

@@ -6,7 +6,7 @@
"strict": true,
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/client"],
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},

View File

@@ -1,9 +1,84 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'Jardin — Gestionnaire de potager',
short_name: 'Jardin',
description: 'Interface mobile-first pour gérer vos jardins, cultures et calendrier lunaire',
theme_color: '#282828',
background_color: '#282828',
display: 'standalone',
lang: 'fr',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 an
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/.*$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 // 24h
}
}
},
{
urlPattern: /\/uploads\/.*$/,
handler: 'CacheFirst',
options: {
cacheName: 'uploads-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 jours
}
}
}
]
}
})
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},