diff --git a/.claude/settings.json b/.claude/settings.json index f424770..79b49e9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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", diff --git a/backend/app/main.py b/backend/app/main.py index 49c280c..b8244f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/migrate.py b/backend/app/migrate.py index 2348509..a983d0a 100644 --- a/backend/app/migrate.py +++ b/backend/app/migrate.py @@ -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 diff --git a/backend/app/models/planting.py b/backend/app/models/planting.py index 7619706..c60f765 100644 --- a/backend/app/models/planting.py +++ b/backend/app/models/planting.py @@ -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 diff --git a/backend/app/routers/gardens.py b/backend/app/routers/gardens.py index 56a3c77..958f025 100644 --- a/backend/app/routers/gardens.py +++ b/backend/app/routers/gardens.py @@ -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() diff --git a/backend/app/routers/plantings.py b/backend/app/routers/plantings.py index 033824f..e597607 100644 --- a/backend/app/routers/plantings.py +++ b/backend/app/routers/plantings.py @@ -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) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 4186400..7aaf541 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -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( diff --git a/backend/app/services/station.py b/backend/app/services/station.py index f478db8..fa158de 100644 --- a/backend/app/services/station.py +++ b/backend/app/services/station.py @@ -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) diff --git a/backend/app/services/yolo_service.py b/backend/app/services/yolo_service.py index f0164e0..34ae50a 100644 --- a/backend/app/services/yolo_service.py +++ b/backend/app/services/yolo_service.py @@ -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 diff --git a/data/jardin.db b/data/jardin.db index 5057da4..87c511b 100755 Binary files a/data/jardin.db and b/data/jardin.db differ diff --git a/data/jardin.db-shm b/data/jardin.db-shm index 60b7eac..aeab45e 100755 Binary files a/data/jardin.db-shm and b/data/jardin.db-shm differ diff --git a/data/jardin.db-wal b/data/jardin.db-wal index 9ceaf03..76e4310 100755 Binary files a/data/jardin.db-wal and b/data/jardin.db-wal differ diff --git a/frontend/index.html b/frontend/index.html index 19ca06b..6bce616 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,10 @@ 🌿 Jardin + + + + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..14192f8 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + 🌿 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ed0a3f8..b438b5e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,9 @@ Disk {{ debugDiskLabel }} + + + @@ -38,8 +41,12 @@ -
- +
+ + + + +
@@ -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' diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e09632..1478479 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 diff --git a/frontend/src/api/gardens.ts b/frontend/src/api/gardens.ts index 84656ca..cbe1cac 100644 --- a/frontend/src/api/gardens.ts +++ b/frontend/src/api/gardens.ts @@ -56,6 +56,10 @@ export const gardensApi = { }, delete: (id: number) => client.delete(`/api/gardens/${id}`), cells: (id: number) => client.get(`/api/gardens/${id}/cells`).then(r => r.data), + createCell: (id: number, cell: Partial) => + client.post(`/api/gardens/${id}/cells`, cell).then(r => r.data), + updateCell: (id: number, cellId: number, cell: Partial) => + client.put(`/api/gardens/${id}/cells/${cellId}`, cell).then(r => r.data), measurements: (id: number) => client.get(`/api/gardens/${id}/measurements`).then(r => r.data), addMeasurement: (id: number, m: Partial) => client.post(`/api/gardens/${id}/measurements`, m).then(r => r.data), diff --git a/frontend/src/api/identify.ts b/frontend/src/api/identify.ts new file mode 100644 index 0000000..8ed4300 --- /dev/null +++ b/frontend/src/api/identify.ts @@ -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('/api/identify', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }).then(r => r.data) + } +} diff --git a/frontend/src/api/plantings.ts b/frontend/src/api/plantings.ts index 0d2aaa7..5786ba1 100644 --- a/frontend/src/api/plantings.ts +++ b/frontend/src/api/plantings.ts @@ -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 diff --git a/frontend/src/components/DiagnosticModal.vue b/frontend/src/components/DiagnosticModal.vue new file mode 100644 index 0000000..e4c91e5 --- /dev/null +++ b/frontend/src/components/DiagnosticModal.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/components/ToastNotification.vue b/frontend/src/components/ToastNotification.vue new file mode 100644 index 0000000..34b6152 --- /dev/null +++ b/frontend/src/components/ToastNotification.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts new file mode 100644 index 0000000..8635fca --- /dev/null +++ b/frontend/src/composables/useToast.ts @@ -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([]) +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, + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c6adb2a..b4df7a2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/style.css b/frontend/src/style.css index 0433b1d..21a6144 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/utils/uiSizeDefaults.ts b/frontend/src/utils/uiSizeDefaults.ts index 1e42c19..30c85d7 100644 --- a/frontend/src/utils/uiSizeDefaults.ts +++ b/frontend/src/utils/uiSizeDefaults.ts @@ -3,6 +3,8 @@ export const UI_SIZE_DEFAULTS: Record = { 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): void { diff --git a/frontend/src/views/AstucesView.vue b/frontend/src/views/AstucesView.vue index f23f1a6..b956d80 100644 --- a/frontend/src/views/AstucesView.vue +++ b/frontend/src/views/AstucesView.vue @@ -1,204 +1,200 @@