Gestion des rencontres passées + interdiction des actions destructrices aux modérateurs
Rencontres passées (section « Ils y étaient » de l'accueil) : - Nouvel onglet « Rencontres passées » (admin + modérateur) : création, édition, jusqu'à 3 photos par rencontre (1 grande + 2 petites, upload après enregistrement — même schéma que la photo des rencontres à venir) - Nouvelles colonnes rencontres_passees.image_path/_2/_3 (migration ALTER TABLE IF NOT EXISTS) - Home.jsx affiche désormais les vraies photos uploadées (repli sur le motif placeholder si absentes) ; ordre passé de ORDER BY id à ORDER BY id DESC (les plus récentes en premier, seed réordonné en conséquence pour préserver l'affichage actuel) - Suppression réservée aux administrateurs Modérateurs : plus aucune action destructrice possible, imposé côté serveur (adminOnly) et masqué côté interface : - Validation/suspension d'un membre (toggle-valide) - Suppression d'une rencontre, d'une rencontre passée, d'une inscription Vérifié : 34 + 21 + 26 tests existants toujours au vert, 21 nouveaux tests (CRUD rencontres passées, upload des 3 emplacements photo, ordre d'affichage, restrictions modérateur sur chaque action désormais admin- only), parcours navigateur complet (upload des 3 photos, rendu réel sur la page d'accueil). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
11 files changed
+426
-55
No files matched your search
@@ -3,13 +3,16 @@ import { api } from '../lib/api.js';
|
||||
|
||||
// Real replacement for the mock's <image-slot>: click or drop an image,
|
||||
// it is uploaded to `endpoint` and the stored path is displayed.
|
||||
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) {
|
||||
// `disabled` shows the placeholder without any upload interaction — used
|
||||
// when the endpoint needs an id that doesn't exist yet (e.g. an entity
|
||||
// that must be saved first).
|
||||
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle, disabled }) {
|
||||
const inputRef = useRef(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const send = async (file) => {
|
||||
if (!file) return;
|
||||
if (!file || disabled) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -25,25 +28,27 @@ export default function ImageSlot({ endpoint, value, onUploaded, placeholder, ci
|
||||
return (
|
||||
<div
|
||||
className={`image-slot${circle ? ' circle' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1 }}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onDragOver={(e) => !disabled && e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
send(e.dataTransfer.files?.[0]);
|
||||
if (!disabled) send(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{value && <img src={value} alt="" />}
|
||||
{!value && <span>{busy ? 'Envoi…' : error || placeholder}</span>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => send(e.target.files?.[0])}
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => send(e.target.files?.[0])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { useAuth } from '../../lib/AuthContext.jsx';
|
||||
import Modal from '../Modal.jsx';
|
||||
import ImageSlot from '../ImageSlot.jsx';
|
||||
|
||||
function PastEventFormModal({ event, onClose, onSaved }) {
|
||||
const [form, setForm] = useState(
|
||||
event
|
||||
? { date_label: event.date_label, lieu: event.lieu, titre: event.titre, texte: event.texte, participants: event.participants, nb_photos: event.nb_photos }
|
||||
: { date_label: '', lieu: '', titre: '', texte: '', participants: 0, nb_photos: 0 }
|
||||
);
|
||||
const [images, setImages] = useState([event?.image_path || '', event?.image_path_2 || '', event?.image_path_3 || '']);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
|
||||
const setImageAt = (i) => (path) => setImages((prev) => prev.map((p, idx) => (idx === i ? path : p)));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
if (event) await api.put(`/api/admin/rencontres-passees/${event.id}`, form);
|
||||
else await api.post('/api/admin/rencontres-passees', form);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm('Supprimer cette rencontre passée et ses photos ?')) return;
|
||||
try {
|
||||
await api.del(`/api/admin/rencontres-passees/${event.id}`);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={620} header={{ kicker: 'Rencontres passées', title: event ? 'Modifier la rencontre passée' : 'Nouvelle rencontre passée' }}>
|
||||
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<div className="field" style={{ marginBottom: 8 }}>Photos</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gridTemplateRows: '1fr 1fr', gap: 8, height: 160 }}>
|
||||
<div style={{ gridRow: 'span 2' }}>
|
||||
<ImageSlot
|
||||
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/1` : undefined}
|
||||
value={images[0]}
|
||||
disabled={!event}
|
||||
placeholder={event ? 'Photo principale' : "Enregistrez d'abord"}
|
||||
onUploaded={setImageAt(0)}
|
||||
/>
|
||||
</div>
|
||||
<ImageSlot
|
||||
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/2` : undefined}
|
||||
value={images[1]}
|
||||
disabled={!event}
|
||||
placeholder="Photo"
|
||||
onUploaded={setImageAt(1)}
|
||||
/>
|
||||
<ImageSlot
|
||||
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/3` : undefined}
|
||||
value={images[2]}
|
||||
disabled={!event}
|
||||
placeholder="Photo"
|
||||
onUploaded={setImageAt(2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">Titre
|
||||
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12 }}>
|
||||
<label className="field">Date affichée
|
||||
<input name="date_label" value={form.date_label} onChange={onChange} placeholder="Juin 2026" required maxLength={60} />
|
||||
</label>
|
||||
<label className="field">Lieu
|
||||
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Description
|
||||
<textarea name="texte" value={form.texte} onChange={onChange} rows={3} required maxLength={2000} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Participants
|
||||
<input name="participants" type="number" min={0} max={100000} value={form.participants} onChange={onChange} required />
|
||||
</label>
|
||||
<label className="field">Nombre de photos affiché
|
||||
<input name="nb_photos" type="number" min={0} max={100000} value={form.nb_photos} onChange={onChange} required />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||
{event ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
{event && (
|
||||
<PastDeleteButton onDelete={remove} />
|
||||
)}
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Only admins can delete — moderators manage this section but can't
|
||||
// destroy data (same rule as members/rencontres/inscriptions).
|
||||
function PastDeleteButton({ onDelete }) {
|
||||
const { user } = useAuth();
|
||||
if (user.role !== 'admin') return null;
|
||||
return (
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={onDelete}>
|
||||
Supprimer
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PastEventsTab() {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [modal, setModal] = useState(null); // null | 'new' | event
|
||||
|
||||
const reload = () => api.get('/api/admin/rencontres-passees').then((d) => setEvents(d.rencontresPassees)).catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
|
||||
<div>
|
||||
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Rencontres passées</h3>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
|
||||
Section « Ils y étaient » de la page d'accueil.
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
|
||||
+ Ajouter une rencontre passée
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{events.map((ev) => (
|
||||
<div key={ev.id} className="card" style={{ borderRadius: 6, padding: '18px 20px', display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 20, alignItems: 'center' }}>
|
||||
<div className={ev.image_path ? '' : 'placeholder-pattern'} style={{ width: 84, height: 60, borderRadius: 4, overflow: 'hidden', flexShrink: 0 }}>
|
||||
{ev.image_path && <img src={ev.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{ev.titre}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>
|
||||
{ev.date_label} · {ev.lieu} · {ev.participants} participants
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setModal(ev)}>
|
||||
Gérer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
|
||||
Aucune rencontre passée pour le moment.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{modal && (
|
||||
<PastEventFormModal
|
||||
event={modal === 'new' ? null : modal}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => { setModal(null); reload(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
|
||||
import { useAuth } from '../../lib/AuthContext.jsx';
|
||||
import Modal from '../Modal.jsx';
|
||||
import ImageSlot from '../ImageSlot.jsx';
|
||||
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
|
||||
|
||||
/* ---------------- Members ---------------- */
|
||||
@@ -90,6 +92,8 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
export function MembersTab() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user.role === 'admin';
|
||||
const [members, setMembers] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [modal, setModal] = useState(null); // null | 'new' | member
|
||||
@@ -176,9 +180,11 @@ export function MembersTab() {
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
|
||||
{m.valide ? 'Suspendre' : 'Valider'}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
|
||||
{m.valide ? 'Suspendre' : 'Valider'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -216,11 +222,13 @@ export function MembersTab() {
|
||||
/* ---------------- Rencontres ---------------- */
|
||||
|
||||
function RencFormModal({ renc, onClose, onSaved }) {
|
||||
const { user } = useAuth();
|
||||
const [form, setForm] = useState(
|
||||
renc
|
||||
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
|
||||
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
|
||||
);
|
||||
const [image, setImage] = useState(renc?.image_path || '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
@@ -251,9 +259,20 @@ function RencFormModal({ renc, onClose, onSaved }) {
|
||||
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
|
||||
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field">Titre de la rencontre
|
||||
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||
<div style={{ width: 120, height: 84, flexShrink: 0 }}>
|
||||
<ImageSlot
|
||||
endpoint={renc ? `/api/admin/rencontres/${renc.id}/image` : undefined}
|
||||
value={image}
|
||||
disabled={!renc}
|
||||
placeholder={renc ? 'Photo de la rencontre' : "Enregistrez d'abord la rencontre"}
|
||||
onUploaded={setImage}
|
||||
/>
|
||||
</div>
|
||||
<label className="field" style={{ flex: 1 }}>Titre de la rencontre
|
||||
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<label className="field" style={{ gridColumn: 'span 2' }}>Date
|
||||
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
|
||||
@@ -279,7 +298,7 @@ function RencFormModal({ renc, onClose, onSaved }) {
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||
{renc ? 'Enregistrer' : 'Créer la rencontre'}
|
||||
</button>
|
||||
{renc && (
|
||||
{renc && user.role === 'admin' && (
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
|
||||
Supprimer
|
||||
</button>
|
||||
@@ -298,6 +317,7 @@ function escHtml(v) {
|
||||
}
|
||||
|
||||
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
|
||||
const { user } = useAuth();
|
||||
const [rows, setRows] = useState([]);
|
||||
const { jour, mois } = dateParts(renc.date_renc);
|
||||
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
|
||||
@@ -370,7 +390,9 @@ function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
|
||||
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
|
||||
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
|
||||
{user.role === 'admin' && (
|
||||
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -480,6 +502,7 @@ export function RencontresTab() {
|
||||
/* ---------------- Inscriptions ---------------- */
|
||||
|
||||
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
|
||||
const { user } = useAuth();
|
||||
const [form, setForm] = useState({
|
||||
nom: inscription.nom,
|
||||
entreprise: inscription.entreprise,
|
||||
@@ -541,9 +564,11 @@ function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onReq
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
|
||||
Annuler l'inscription
|
||||
</button>
|
||||
{user.role === 'admin' && (
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
|
||||
Annuler l'inscription
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Fermer
|
||||
</button>
|
||||
@@ -587,6 +612,7 @@ function CancelConfirmModal({ inscription, onClose, onDone }) {
|
||||
}
|
||||
|
||||
export function InscriptionsTab() {
|
||||
const { user } = useAuth();
|
||||
const [inscriptions, setInscriptions] = useState([]);
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [edit, setEdit] = useState(null);
|
||||
@@ -623,7 +649,9 @@ export function InscriptionsTab() {
|
||||
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
|
||||
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
|
||||
{user.role === 'admin' && (
|
||||
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -4,15 +4,18 @@ import { useAuth } from '../lib/AuthContext.jsx';
|
||||
import { api, dateParts, statutLabel } from '../lib/api.js';
|
||||
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
|
||||
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
|
||||
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
|
||||
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
||||
|
||||
// Moderators only get members/rencontres/inscriptions — everything else
|
||||
// (dashboard, taxonomy, site content, staff account management) is
|
||||
// admin-only, enforced both here (sidebar) and server-side (routes).
|
||||
// Moderators only get members/rencontres/inscriptions/rencontres passées
|
||||
// (create + edit, never delete) — everything else (dashboard, taxonomy,
|
||||
// site content, staff account management) is admin-only, enforced both
|
||||
// here (sidebar) and server-side (routes).
|
||||
const TABS = [
|
||||
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
|
||||
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] },
|
||||
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] },
|
||||
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] },
|
||||
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
|
||||
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
|
||||
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
|
||||
@@ -143,6 +146,7 @@ export function AdminShell() {
|
||||
{tab === 'dashboard' && <Dashboard />}
|
||||
{tab === 'membres' && <MembersTab />}
|
||||
{tab === 'rencontres' && <RencontresTab />}
|
||||
{tab === 'passees' && <PastEventsTab />}
|
||||
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||
{tab === 'categories' && <CategoriesTab />}
|
||||
{tab === 'contenu' && <ContenuTab />}
|
||||
|
||||
+13
-4
@@ -213,7 +213,10 @@ export default function Home() {
|
||||
const restantes = Math.max(0, e.places - e.inscrits);
|
||||
return (
|
||||
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="placeholder-pattern" style={{ height: 170, position: 'relative' }}>
|
||||
<div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}>
|
||||
{e.image_path && (
|
||||
<img src={e.image_path} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
<div style={{ position: 'absolute', top: 16, left: 16, background: 'var(--dark)', color: '#fff', borderRadius: 3, padding: '8px 12px', textAlign: 'center', lineHeight: 1.05 }}>
|
||||
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: '#B7AFA6' }}>{mois}</div>
|
||||
@@ -262,9 +265,15 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
|
||||
<div className="placeholder-pattern" style={{ gridRow: 'span 2', borderRadius: 3 }} />
|
||||
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||
<div className={p.image_path ? '' : 'placeholder-pattern'} style={{ gridRow: 'span 2', borderRadius: 3, overflow: 'hidden' }}>
|
||||
{p.image_path && <img src={p.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||
</div>
|
||||
<div className={p.image_path_2 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
|
||||
{p.image_path_2 && <img src={p.image_path_2} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||
</div>
|
||||
<div className={p.image_path_3 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
|
||||
{p.image_path_3 && <img src={p.image_path_3} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user