feat: implement event submission flow with image upload and modal interface

This commit is contained in:
2026-05-10 21:47:28 +02:00
parent 7db35361d9
commit 197594f84f
31 changed files with 1481 additions and 75 deletions

View File

@@ -17,6 +17,9 @@ export async function createBlogPost(data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -51,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -18,6 +18,8 @@ export async function createEvent(data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -53,6 +55,8 @@ export async function updateEvent(id: string, data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -27,6 +27,8 @@ export async function createSlide(data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");
@@ -50,6 +52,8 @@ export async function updateSlide(id: string, data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");

View File

@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
const buffer = Buffer.from(bytes);
// Create a unique filename
const fileExtension = file.name.split('.').pop();
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
// Path for the ROOT public/uploads (where the main site will look)

View File

@@ -299,3 +299,23 @@ body {
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
background-color: var(--primary);
}
/* Styles pour les tableaux dans l'éditeur */
.quill-dark-wrapper .ql-editor table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
border: 1px solid var(--border);
}
.quill-dark-wrapper .ql-editor th,
.quill-dark-wrapper .ql-editor td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.quill-dark-wrapper .ql-editor th {
background-color: var(--secondary);
font-weight: 600;
}

View File

@@ -13,8 +13,11 @@ import {
Search,
ArrowUp,
ArrowDown,
Calendar
Calendar,
Map as MapIcon
} from 'lucide-react';
import AnalyticsMap from '@/components/AnalyticsMap';
import CountryHistogram from '@/components/CountryHistogram';
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
if (value === null) return null;
@@ -76,12 +79,28 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
const topCountries = await prisma.analyticsEvent.groupBy({
by: ['country'],
where: currentWhere,
where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10
take: 50 // Increased to get more map data
});
// Unique visitors per country
const uniqueVisitorsPerCountryRaw = await prisma.analyticsEvent.groupBy({
by: ['country', 'ip'],
where: { type: 'PAGE_VIEW', ...currentWhere }
});
const uniqueVisitorsPerCountryMap: Record<string, number> = {};
uniqueVisitorsPerCountryRaw.forEach(item => {
const country = item.country || 'Unknown';
uniqueVisitorsPerCountryMap[country] = (uniqueVisitorsPerCountryMap[country] || 0) + 1;
});
const uniqueVisitorsPerCountry = Object.entries(uniqueVisitorsPerCountryMap)
.map(([country, count]) => ({ country, count }))
.sort((a, b) => b.count - a.count);
const devices = await prisma.analyticsEvent.groupBy({
by: ['device'],
where: currentWhere,
@@ -156,6 +175,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
totalEvents,
uniqueVisitors,
recentIPs,
uniqueVisitorsPerCountry,
deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
@@ -169,7 +189,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
const searchParams = await searchParamsPromise;
const range = searchParams.range || 'week';
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry } = await getMetrics(range, searchParams.from, searchParams.to);
const rangeLabels: Record<string, string> = {
day: 'Dernières 24h',
@@ -237,13 +257,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Users className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
<div className="text-2xl font-bold text-white">{uniqueVisitors.toLocaleString()}</div>
<ComparisonBadge value={deltas.visitors} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Personnes réelles distinctes
</p>
</div>
@@ -253,13 +273,13 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Eye className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{totalEvents}</div>
<div className="text-2xl font-bold text-white">{totalEvents.toLocaleString()}</div>
<ComparisonBadge value={deltas.events} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
<h3 className="text-slate-400 text-sm font-medium">Impressions (Pages vues)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Nombre total de fois les pages ont é vues
</p>
</div>
@@ -275,13 +295,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<ComparisonBadge value={deltas.mobile} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
<h3 className="text-slate-400 text-sm font-medium">Trafic Mobile</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Part des appareils mobiles
</p>
</div>
</div>
{/* World Map Section */}
<AnalyticsMap
data={uniqueVisitorsPerCountry}
title="Répartition Géographique (Visiteurs Uniques)"
/>
{/* Histogram Section */}
<CountryHistogram
title="Comparatif Impressions vs Visiteurs Uniques"
data={topCountries.map(c => ({
country: c.country || 'Inconnu',
impressions: c._count.id,
visitors: uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0
}))}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Countries Table */}
<div className="card p-0 overflow-hidden">
@@ -294,18 +330,28 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Pays</th>
<th className="px-6 py-4 text-right">Visites</th>
<th className="px-6 py-4 text-right">Impressions</th>
<th className="px-6 py-4 text-right">V. Uniques</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topCountries.map((c) => (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{c._count.id}
</td>
</tr>
))}
{topCountries.map((c) => {
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
return (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
{c.country || 'Inconnu'}
</td>
<td className="px-6 py-4 text-right font-medium text-slate-400">
{c._count.id.toLocaleString()}
</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{uniqueCount.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
@@ -26,6 +26,9 @@ interface Props {
metaDescription?: string | null;
publishedAt?: Date | string | null;
status?: ContentStatus;
coverPosition?: string | null;
coverZoom?: number | null;
sources?: any;
};
}
@@ -34,6 +37,8 @@ export default function BlogForm({ initialData }: Props) {
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
@@ -41,6 +46,9 @@ export default function BlogForm({ initialData }: Props) {
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const [sources, setSources] = useState<{ title: string; url: string }[]>(
Array.isArray(initialData?.sources) ? initialData.sources : []
);
const isPublished = new Date(publishedAtValue) <= new Date();
@@ -65,6 +73,9 @@ export default function BlogForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
sources: sources,
};
if (!data.imageUrl) {
@@ -148,6 +159,11 @@ export default function BlogForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">
@@ -187,6 +203,61 @@ export default function BlogForm({ initialData }: Props) {
placeholder="Rédigez votre article ici..."
/>
</div>
{/* SOURCES SECTION */}
<div className="pt-6 border-t border-slate-800">
<div className="flex items-center justify-between mb-4">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-brand-500" />
Sources & Liens externes
</label>
<button
type="button"
onClick={() => setSources([...sources, { title: '', url: '' }])}
className="text-xs font-bold text-brand-500 hover:text-brand-400 flex items-center gap-1 bg-brand-500/10 px-3 py-1.5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
Ajouter une source
</button>
</div>
<div className="space-y-3">
{sources.map((source, index) => (
<div key={index} className="flex gap-3 animate-in fade-in slide-in-from-top-1 duration-200">
<input
placeholder="Nom de la source (ex: Le Monde)"
value={source.title}
onChange={(e) => {
const newSources = [...sources];
newSources[index].title = e.target.value;
setSources(newSources);
}}
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
<input
placeholder="URL (ex: https://...)"
value={source.url}
onChange={(e) => {
const newSources = [...sources];
newSources[index].url = e.target.value;
setSources(newSources);
}}
className="flex-2 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={() => setSources(sources.filter((_, i) => i !== index))}
className="p-2.5 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
{sources.length === 0 && (
<p className="text-xs text-slate-600 italic">Aucune source ajoutée pour cet article.</p>
)}
</div>
</div>
</div>
{/* SEO SECTION */}

View File

@@ -0,0 +1,197 @@
"use client";
import React, { useMemo } from "react";
import {
ComposableMap,
Geographies,
Geography,
Sphere,
Graticule,
ZoomableGroup
} from "react-simple-maps";
import { scaleLinear } from "d3-scale";
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
// URL for the world map TopoJSON
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
interface AnalyticsMapProps {
data: { country: string; count: number }[];
title: string;
}
// Simple mapping for common countries to ISO Alpha-3 codes used by world-atlas
// In a real app, this should be handled at the tracking level or via a robust library
const countryToISO: Record<string, string> = {
"France": "FRA",
"United States": "USA",
"Canada": "CAN",
"United Kingdom": "GBR",
"Germany": "DEU",
"Ivory Coast": "CIV",
"Côte d'Ivoire": "CIV",
"Senegal": "SEN",
"Sénégal": "SEN",
"Cameroon": "CMR",
"Cameroun": "CMR",
"Mali": "MLI",
"Benin": "BEN",
"Bénin": "BEN",
"Togo": "TGO",
"Gabon": "GAB",
"Congo": "COG",
"DR Congo": "COD",
"Nigeria": "NGA",
"Ghana": "GHA",
"Morocco": "MAR",
"Maroc": "MAR",
"Algeria": "DZA",
"Algérie": "DZA",
"Tunisia": "TUN",
"Tunisie": "TUN",
"Belgium": "BEL",
"Belgique": "BEL",
"Switzerland": "CHE",
"Suisse": "CHE",
"Spain": "ESP",
"Espagne": "ESP",
"Italy": "ITA",
"Italie": "ITA",
};
export default function AnalyticsMap({ data, title }: AnalyticsMapProps) {
const maxCount = useMemo(() => {
if (data.length === 0) return 1;
return Math.max(...data.map((d) => d.count));
}, [data]);
const colorScale = scaleLinear<string>()
.domain([0, maxCount])
.range(["#1e293b", "#6366f1"]);
const mappedData = useMemo(() => {
const map: Record<string, number> = {};
data.forEach((d) => {
const iso = countryToISO[d.country] || d.country; // Fallback to name if not in map
map[iso] = d.count;
});
return map;
}, [data]);
const [position, setPosition] = React.useState({ coordinates: [0, 0], zoom: 1 });
function handleZoomIn() {
if (position.zoom >= 4) return;
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
}
function handleZoomOut() {
if (position.zoom <= 1) return;
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
}
function handleReset() {
setPosition({ coordinates: [0, 0], zoom: 1 });
}
function handleMoveEnd(position: { coordinates: [number, number]; zoom: number }) {
setPosition(position);
}
return (
<div className="card p-0 overflow-hidden bg-slate-900/50 border border-slate-800 relative group">
<div className="p-6 border-b border-slate-800 flex items-center justify-between mb-2">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
{title}
</h2>
{/* Zoom Controls */}
<div className="flex items-center gap-2 bg-slate-800 p-1.5 rounded-lg border border-slate-700">
<button
onClick={handleZoomIn}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Zoomer"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomOut}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Dézoomer"
>
<ZoomOut className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-slate-700 mx-1" />
<button
onClick={handleReset}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Réinitialiser la vue"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 flex justify-center bg-slate-950/20 h-[600px]">
<ComposableMap
projectionConfig={{
rotate: [-10, 0, 0],
scale: 147
}}
width={800}
height={600}
style={{ width: "100%", height: "100%" }}
>
<ZoomableGroup
zoom={position.zoom}
center={position.coordinates as [number, number]}
onMoveEnd={handleMoveEnd}
>
<Sphere stroke="#334155" strokeWidth={0.5} id="sphere" fill="transparent" />
<Graticule stroke="#334155" strokeWidth={0.5} />
{data.length > 0 && (
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const countryName = geo.properties.name;
const countryId = geo.id;
const countryISO = geo.properties.iso_a3;
const count = mappedData[countryName] || mappedData[countryISO] || mappedData[countryId] || 0;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={count > 0 ? colorScale(count) : "#0f172a"}
stroke="#1e293b"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "#4f46e5", outline: "none", cursor: "pointer" },
pressed: { outline: "none" }
}}
/>
);
})
}
</Geographies>
)}
</ZoomableGroup>
</ComposableMap>
</div>
{/* Legend / Instructions */}
<div className="absolute bottom-4 left-4 bg-slate-900/80 backdrop-blur-md border border-slate-700 p-3 rounded-lg text-[10px] text-slate-400 uppercase font-bold tracking-widest pointer-events-none">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded bg-[#0f172a] border border-slate-700"></span>
<span>Pas de trafic</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"></span>
<span>Trafic identifié</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell
} from 'recharts';
interface CountryHistogramProps {
data: { country: string; impressions: number; visitors: number }[];
title: string;
}
export default function CountryHistogram({ data, title }: CountryHistogramProps) {
// Take top 10 for readability
const chartData = data.slice(0, 10).map(item => ({
name: item.country || 'Inconnu',
"Impressions": item.impressions,
"V. Uniques": item.visitors
}));
return (
<div className="card bg-slate-900/50 border border-slate-800 p-6">
<h2 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
{title}
</h2>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis
dataKey="name"
stroke="#64748b"
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis
stroke="#64748b"
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => value.toLocaleString()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
fontSize: '12px'
}}
itemStyle={{ padding: '2px 0' }}
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{ paddingBottom: '20px', fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold' }}
/>
<Bar
dataKey="Impressions"
fill="#6366f1"
radius={[4, 4, 0, 0]}
barSize={20}
animationDuration={1500}
/>
<Bar
dataKey="V. Uniques"
fill="#10b981"
radius={[4, 4, 0, 0]}
barSize={20}
animationDuration={1500}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -21,6 +21,8 @@ export default function EventForm({ initialData }: Props) {
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
@@ -53,6 +55,8 @@ export default function EventForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
if (!data.thumbnailUrl) {
@@ -170,6 +174,11 @@ export default function EventForm({ initialData }: Props) {
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">

View File

@@ -11,9 +11,25 @@ interface Props {
label?: string;
placeholder?: string;
name?: string;
showPositionControls?: boolean;
position?: string;
zoom?: number;
onPositionChange?: (value: string) => void;
onZoomChange?: (value: number) => void;
}
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
export default function ImageUploader({
value,
onChange,
label,
placeholder,
name,
showPositionControls = false,
position = "50% 50%",
zoom = 1,
onPositionChange,
onZoomChange
}: Props) {
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
return;
}
@@ -120,37 +137,111 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
</div>
</div>
) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
>
<Upload className="w-5 h-5" />
</button>
<button
type="button"
onClick={clearImage}
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
style={{
objectPosition: position,
transform: `scale(${zoom})`,
transformOrigin: position
}}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
>
<Upload className="w-5 h-5" />
</button>
<button
type="button"
onClick={clearImage}
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hidden inputs for values to be sent via form if needed */}
<input type="hidden" name={name} value={value || ""} />
{showPositionControls && (
<>
<input type="hidden" name={`${name}Position`} value={position} />
<input type="hidden" name={`${name}Zoom`} value={zoom.toString()} />
</>
)}
</div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value || ""} />
{showPositionControls && (
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-800 space-y-4 animate-in fade-in duration-300">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex justify-between">
<label className="text-[10px] font-bold text-slate-500 uppercase">Zoom : {zoom.toFixed(1)}x</label>
<button
type="button"
onClick={() => onZoomChange?.(1)}
className="text-[10px] text-indigo-400 hover:text-white"
>
Reset
</button>
</div>
<input
type="range"
min="1"
max="3"
step="0.1"
value={zoom}
onChange={(e) => onZoomChange?.(parseFloat(e.target.value))}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Position X : {position.split(' ')[0]}</label>
<input
type="range"
min="0"
max="100"
step="1"
value={parseInt(position.split(' ')[0])}
onChange={(e) => {
const y = position.split(' ')[1];
onPositionChange?.(`${e.target.value}% ${y}`);
}}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Position Y : {position.split(' ')[1]}</label>
<input
type="range"
min="0"
max="100"
step="1"
value={parseInt(position.split(' ')[1])}
onChange={(e) => {
const x = position.split(' ')[0];
onPositionChange?.(`${x} ${e.target.value}%`);
}}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div>
)}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
className="hidden"
/>
</div>

View File

@@ -34,12 +34,13 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
const quillRef = useRef<any>(null);
const modules = useMemo(() => ({
table: true,
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['divider', 'link', 'clean'],
['table', 'divider', 'link', 'clean'],
],
handlers: {
divider: function() {
@@ -58,7 +59,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
'header',
'bold', 'italic', 'underline', 'strike',
'list',
'divider', 'link',
'divider', 'link', 'table'
];
return (

View File

@@ -19,6 +19,8 @@ export default function SlideForm({ initialData }: Props) {
// Form state
const [type, setType] = useState(initialData?.type || 'CUSTOM');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
// Business search state
@@ -55,6 +57,8 @@ export default function SlideForm({ initialData }: Props) {
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
order: parseInt(formData.get('order') as string) || 0,
isActive,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
if (type === 'BUSINESS' && !data.businessId) {
@@ -232,6 +236,11 @@ export default function SlideForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
)}