avant 50
This commit is contained in:
@@ -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>
|
||||
|
||||
<!-- 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="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>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user