feat: implement event submission flow with image upload and modal interface
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 où les pages ont été 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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
197
admin/src/components/AnalyticsMap.tsx
Normal file
197
admin/src/components/AnalyticsMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
admin/src/components/CountryHistogram.tsx
Normal file
97
admin/src/components/CountryHistogram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user