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>
|
||||
|
||||
Reference in New Issue
Block a user