Merge pull request #105 from R0m1k3/claude/analyze-codebase-01XdqnCRHEvL9VWeHDNGVPkA

feat: Ajouter versions mobiles complètes pour toutes les pages
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-29 12:01:44 +01:00
commit 54725c7088
13 files changed
+3647 -10

No files matched your search

+52 -10
View File
@@ -4,19 +4,61 @@ import { queryClient } from "./lib/queryClient";
import { QueryClientProvider, useQuery } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { createResponsiveRoute } from "@/components/responsive-route";
import NotFound from "@/pages/not-found";
import Login from "@/pages/login";
import Dashboard from "@/pages/dashboard";
import NewPost from "@/pages/new-post";
import Calendar from "@/pages/calendar";
import Media from "@/pages/media";
import ImageEditor from "@/pages/image-editor";
import PagesManagement from "@/pages/pages";
import AI from "@/pages/ai";
import History from "@/pages/history";
import Settings from "@/pages/settings";
import SqlAdmin from "@/pages/sql";
import UsersAdmin from "@/pages/users-admin";
// Create responsive routes with lazy loading for optimal performance
const Dashboard = createResponsiveRoute(
() => import("@/pages/dashboard"),
() => import("@/pages/mobile/dashboard")
);
const NewPost = createResponsiveRoute(
() => import("@/pages/new-post"),
() => import("@/pages/mobile/new-post")
);
const Calendar = createResponsiveRoute(
() => import("@/pages/calendar"),
() => import("@/pages/mobile/calendar")
);
const Media = createResponsiveRoute(
() => import("@/pages/media"),
() => import("@/pages/mobile/media")
);
const ImageEditor = createResponsiveRoute(
() => import("@/pages/image-editor"),
() => import("@/pages/mobile/image-editor")
);
const PagesManagement = createResponsiveRoute(
() => import("@/pages/pages"),
() => import("@/pages/mobile/pages")
);
const AI = createResponsiveRoute(
() => import("@/pages/ai"),
() => import("@/pages/mobile/ai")
);
const Settings = createResponsiveRoute(
() => import("@/pages/settings"),
() => import("@/pages/mobile/settings")
);
const SqlAdmin = createResponsiveRoute(
() => import("@/pages/sql"),
() => import("@/pages/mobile/sql")
);
const UsersAdmin = createResponsiveRoute(
() => import("@/pages/users-admin"),
() => import("@/pages/mobile/users-admin")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
const [, setLocation] = useLocation();
@@ -0,0 +1,49 @@
import { ComponentType, Suspense, lazy } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
interface ResponsiveRouteProps {
desktop: ComponentType<any>;
mobile: ComponentType<any>;
fallback?: React.ReactNode;
}
/**
* ResponsiveRoute component that automatically switches between desktop and mobile versions
* Uses lazy loading for optimal performance
*/
export function ResponsiveRoute({ desktop: Desktop, mobile: Mobile, fallback }: ResponsiveRouteProps) {
const isMobile = useIsMobile();
// Default loading fallback
const defaultFallback = (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
return (
<Suspense fallback={fallback || defaultFallback}>
{isMobile ? <Mobile /> : <Desktop />}
</Suspense>
);
}
/**
* Factory function to create lazy-loaded responsive routes
* Usage:
* const Dashboard = createResponsiveRoute(
* () => import('./pages/dashboard'),
* () => import('./pages/mobile/dashboard')
* );
*/
export function createResponsiveRoute(
desktopImport: () => Promise<{ default: ComponentType<any> }>,
mobileImport: () => Promise<{ default: ComponentType<any> }>
) {
const DesktopLazy = lazy(desktopImport);
const MobileLazy = lazy(mobileImport);
return function ResponsiveRouteWrapper() {
return <ResponsiveRoute desktop={DesktopLazy} mobile={MobileLazy} />;
};
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
/**
* Hook to detect if the user is on a mobile device
* Uses both media query and user agent for robust detection
*
* @param breakpoint - The max-width breakpoint for mobile (default: 768px)
* @returns boolean - true if mobile, false if desktop
*/
export function useIsMobile(breakpoint: number = 768): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
// Initial detection (SSR-safe)
if (typeof window === 'undefined') return false;
// Check media query
const mediaMatch = window.matchMedia(`(max-width: ${breakpoint}px)`).matches;
// Check user agent for mobile devices
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['android', 'webos', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
const isUserAgentMobile = mobileKeywords.some(keyword => userAgent.includes(keyword));
// Return true if either media query or user agent indicates mobile
return mediaMatch || isUserAgentMobile;
});
useEffect(() => {
// Create media query list
const mediaQueryList = window.matchMedia(`(max-width: ${breakpoint}px)`);
// Handler for media query changes
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
// Listen for changes (modern browsers)
if (mediaQueryList.addEventListener) {
mediaQueryList.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQueryList.addListener(handleChange);
}
// Cleanup
return () => {
if (mediaQueryList.removeEventListener) {
mediaQueryList.removeEventListener('change', handleChange);
} else {
mediaQueryList.removeListener(handleChange);
}
};
}, [breakpoint]);
return isMobile;
}
/**
* Hook to detect device type with more granularity
* @returns object with device type information
*/
export function useDeviceType() {
const isMobile = useIsMobile(768);
const isTablet = useIsMobile(1024) && !useIsMobile(768);
const isDesktop = !useIsMobile(1024);
return {
isMobile,
isTablet,
isDesktop,
deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'
} as const;
}
+42
View File
@@ -0,0 +1,42 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import AiChat from "@/components/ai-chat";
export default function AIMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Assistant IA</h1>
<p className="text-sm text-muted-foreground">
Générez du contenu personnalisé avec l'intelligence artificielle
</p>
</div>
<AiChat />
</div>
</main>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import CalendarView from "@/components/calendar-view";
export default function CalendarMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout with reduced padding */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Calendrier</h1>
<p className="text-sm text-muted-foreground">
Visualisez et gérez vos publications planifiées
</p>
</div>
<CalendarView />
</div>
</main>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import StatsCards from "@/components/stats-cards";
import RecentPublications from "@/components/recent-publications";
import ManagedPages from "@/components/managed-pages";
import { useState } from "react";
export default function DashboardMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Reduced padding, single column layout with better spacing */}
<div className="p-4 space-y-6">
<StatsCards />
<RecentPublications />
<ManagedPages />
</div>
</main>
</div>
);
}
+643
View File
@@ -0,0 +1,643 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Wand2, Save, Image as ImageIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface Ribbon {
enabled: boolean;
text: "Promo" | "Nouveauté" | "Arrivage" | "Stock limité";
color: "red" | "yellow";
position: "north_west" | "north_east";
}
interface PriceBadge {
enabled: boolean;
price: string;
color: "red" | "yellow";
position: "north_east" | "south_east" | "north_west" | "south_west";
size: number;
}
interface Logo {
enabled: boolean;
opacity: number;
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
size: "small" | "medium" | "large";
}
export default function ImageEditorMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<any>(null);
const [ribbon, setRibbon] = useState<Ribbon>({
enabled: false,
text: "Promo",
color: "red",
position: "north_west"
});
const [priceBadge, setPriceBadge] = useState<PriceBadge>({
enabled: false,
price: "",
color: "red",
position: "north_east",
size: 20
});
const [logo, setLogo] = useState<Logo>({
enabled: false,
opacity: 60,
position: "south_east",
size: "medium"
});
const [previewUrl, setPreviewUrl] = useState<string>("");
const { data: mediaList } = useQuery({
queryKey: ["/api/media"],
});
const { data: cloudinaryConfig } = useQuery({
queryKey: ['/api/cloudinary/config'],
});
const previewRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const saveImageMutation = useMutation({
mutationFn: async () => {
if (!selectedMedia) {
throw new Error("Aucune image sélectionnée");
}
const payload = {
imageUrl: selectedMedia.originalUrl,
ribbon: ribbon.enabled ? ribbon : undefined,
priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined,
logo: logo.enabled ? logo : undefined,
};
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
toast({
title: "Image enregistrée !",
description: "L'image éditée a été ajoutée à votre médiathèque.",
});
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message || "Impossible d'enregistrer l'image.",
variant: "destructive"
});
}
});
useEffect(() => {
if (!selectedMedia) {
setPreviewUrl("");
return;
}
let url = selectedMedia.originalUrl;
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
const match = url.match(cloudinaryPattern);
if (!match) {
setPreviewUrl(url);
return;
}
const [fullMatch, cloudName] = match;
const imagePath = url.replace(fullMatch, '');
const transformations: string[] = [];
transformations.push('w_800,h_800,c_fit');
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
setPreviewUrl(transformedUrl);
}, [selectedMedia, ribbon, priceBadge, logo]);
return (
<div className="flex h-screen overflow-hidden bg-background">
<style>{`
.ribbon-container {
position: absolute;
z-index: 10;
pointer-events: none;
}
.ribbon-container.north_west {
top: 0;
left: 0;
width: 140px;
height: 140px;
}
.ribbon-container.north_east {
top: 0;
right: 0;
width: 140px;
height: 140px;
}
.ribbon-triangle {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.ribbon-container.north_west .ribbon-triangle {
top: 0;
left: 0;
border-width: 140px 140px 0 0;
}
.ribbon-container.north_west .ribbon-triangle.red {
border-top-color: #FF0000;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_west .ribbon-triangle.yellow {
border-top-color: #FFC107;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle {
top: 0;
right: 0;
border-width: 0 140px 140px 0;
}
.ribbon-container.north_east .ribbon-triangle.red {
border-top-color: transparent;
border-right-color: #FF0000;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle.yellow {
border-top-color: transparent;
border-right-color: #FFC107;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-text {
position: absolute;
color: white;
font-weight: bold;
z-index: 11;
text-transform: uppercase;
text-align: center;
white-space: nowrap;
}
.ribbon-container.north_west .ribbon-text {
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg) translateY(-20px);
}
.ribbon-container.north_east .ribbon-text {
top: 50%;
right: 50%;
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
}
`}</style>
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Wand2 className="w-7 h-7" />
Éditeur d'images
</h1>
<p className="text-sm text-muted-foreground">
Ajoutez des rubans, badges prix et filtres
</p>
</div>
{/* Preview Card - Top on mobile */}
{selectedMedia && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center relative">
<div ref={previewRef} className="relative max-w-full max-h-full">
<img
src={previewUrl || selectedMedia.originalUrl}
alt="Aperçu"
className="max-w-full max-h-[400px] w-auto h-auto"
data-testid="preview-image"
/>
{ribbon.enabled && ribbon.text && (
<div className={`ribbon-container ${ribbon.position}`}>
<div className={`ribbon-triangle ${ribbon.color}`} />
<div
className="ribbon-text"
style={{
fontSize: ribbon.text === "Stock limité" ? '18px' :
ribbon.text.length <= 5 ? '24px' :
ribbon.text.length <= 8 ? '20px' :
ribbon.text.length <= 11 ? '18px' : '14px',
lineHeight: ribbon.text === "Stock limité" ? '1.2' : 'normal',
whiteSpace: ribbon.text === "Stock limité" ? 'normal' : 'nowrap',
transform: ribbon.text === "Stock limité"
? (ribbon.position === "north_west"
? 'translate(-50%, -50%) rotate(-45deg) translateY(-30px) translateX(2px)'
: 'translate(50%, -50%) rotate(45deg) translateY(-30px) translateX(-2px)')
: undefined
}}
>
{ribbon.text === "Stock limité" ? (
<>
Stock
<br />
limité
</>
) : ribbon.text}
</div>
</div>
)}
{priceBadge.enabled && priceBadge.price && (
<div
data-badge-price="true"
className={`absolute ${
priceBadge.position === "north_east" ? "top-3 right-3" :
priceBadge.position === "south_east" ? "bottom-3 right-3" :
priceBadge.position === "south_west" ? "bottom-3 left-3" :
"top-3 left-3"
} px-4 py-2 rounded-full text-white font-bold z-10 flex items-center justify-center ${
priceBadge.color === "red" ? "bg-red-600" : "bg-yellow-500"
}`}
style={{ fontSize: `${priceBadge.size}px` }}
>
{priceBadge.price} €
</div>
)}
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
<img
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
alt="Logo"
className={`absolute ${
logo.position === "north_east" ? "top-3 right-3" :
logo.position === "south_east" ? "bottom-3 right-3" :
logo.position === "south_west" ? "bottom-3 left-3" :
logo.position === "center" ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" :
"top-3 left-3"
} z-10 max-w-full max-h-full object-contain`}
style={{
opacity: logo.opacity / 100,
width: logo.size === "small" ? "30%" : logo.size === "large" ? "60%" : "45%"
}}
/>
)}
</div>
<button
onClick={() => setSelectedMedia(null)}
className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full text-white transition-colors z-20 min-h-[44px] min-w-[44px] flex items-center justify-center"
data-testid="button-clear-selection"
title="Désélectionner l'image"
>
<X className="w-5 h-5" />
</button>
</div>
<Button
className="w-full min-h-[48px]"
variant="default"
data-testid="button-save"
onClick={() => saveImageMutation.mutate()}
disabled={saveImageMutation.isPending}
>
<Save className="w-5 h-5 mr-2" />
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Image Selection */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<ImageIcon className="w-5 h-5" />
Sélectionner une image
</CardTitle>
</CardHeader>
<CardContent>
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
<div className="grid grid-cols-2 gap-3 max-h-[300px] overflow-y-auto">
{(mediaList as any[])
.filter((m: any) => m.type === 'image')
.map((media: any) => (
<div
key={media.id}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
}`}
onClick={() => setSelectedMedia(media)}
data-testid={`select-image-${media.id}`}
>
<img
src={media.instagramFeedUrl || media.originalUrl}
alt=""
className="w-full h-28 object-cover"
/>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
Aucune image disponible. Uploadez des images dans la médiathèque.
</p>
)}
</CardContent>
</Card>
{/* Ribbon Controls */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Ruban promotionnel</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="ribbon-enabled"
checked={ribbon.enabled}
onCheckedChange={(checked) => setRibbon({ ...ribbon, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-ribbon"
/>
<Label htmlFor="ribbon-enabled" className="cursor-pointer flex-1 text-sm">Afficher un ruban</Label>
</div>
{ribbon.enabled && (
<>
<div className="space-y-3">
<Label className="text-sm">Texte du ruban</Label>
<RadioGroup
value={ribbon.text}
onValueChange={(value) => setRibbon({ ...ribbon, text: value as any })}
className="space-y-2"
>
{["Promo", "Nouveauté", "Arrivage", "Stock limité"].map((text) => (
<div key={text} className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value={text} id={`ribbon-${text.toLowerCase()}`} data-testid={`radio-ribbon-${text.toLowerCase()}`} />
<Label htmlFor={`ribbon-${text.toLowerCase()}`} className="cursor-pointer flex-1 text-sm">{text}</Label>
</div>
))}
</RadioGroup>
</div>
<div className="space-y-3">
<Label className="text-sm">Couleur</Label>
<RadioGroup
value={ribbon.color}
onValueChange={(value) => setRibbon({ ...ribbon, color: value as any })}
className="space-y-2"
>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="red" id="ribbon-red" data-testid="radio-ribbon-red" />
<Label htmlFor="ribbon-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Rouge <Badge className="bg-red-600">Exemple</Badge>
</Label>
</div>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="yellow" id="ribbon-yellow" data-testid="radio-ribbon-yellow" />
<Label htmlFor="ribbon-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Jaune <Badge className="bg-yellow-500">Exemple</Badge>
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={ribbon.position} onValueChange={(value) => setRibbon({ ...ribbon, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-ribbon-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="north_east">Haut droite</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</CardContent>
</Card>
{/* Price Badge */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Badge prix</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="price-enabled"
checked={priceBadge.enabled}
onCheckedChange={(checked) => setPriceBadge({ ...priceBadge, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-price"
/>
<Label htmlFor="price-enabled" className="cursor-pointer flex-1 text-sm">Afficher le prix</Label>
</div>
{priceBadge.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="price-input" className="text-sm">Prix (€)</Label>
<Input
id="price-input"
type="text"
placeholder="19.99"
value={priceBadge.price}
onChange={(e) => setPriceBadge({ ...priceBadge, price: e.target.value })}
className="min-h-[48px]"
data-testid="input-price"
/>
</div>
<div className="space-y-3">
<Label className="text-sm">Couleur</Label>
<RadioGroup
value={priceBadge.color}
onValueChange={(value) => setPriceBadge({ ...priceBadge, color: value as any })}
className="space-y-2"
>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="red" id="price-red" data-testid="radio-price-red" />
<Label htmlFor="price-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Rouge <Badge className="bg-red-600">19.99€</Badge>
</Label>
</div>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="yellow" id="price-yellow" data-testid="radio-price-yellow" />
<Label htmlFor="price-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Jaune <Badge className="bg-yellow-500">19.99€</Badge>
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={priceBadge.position} onValueChange={(value) => setPriceBadge({ ...priceBadge, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-price-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_east">Haut droite</SelectItem>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="south_east">Bas droite</SelectItem>
<SelectItem value="south_west">Bas gauche</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-sm">Taille du texte</Label>
<span className="text-sm text-muted-foreground">{priceBadge.size}px</span>
</div>
<Slider
value={[priceBadge.size]}
onValueChange={(value) => setPriceBadge({ ...priceBadge, size: value[0] })}
min={12}
max={70}
step={1}
className="touch-manipulation"
data-testid="slider-price-size"
/>
</div>
</>
)}
</CardContent>
</Card>
{/* Logo */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Logo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="logo-enabled"
checked={logo.enabled}
onCheckedChange={(checked) => setLogo({ ...logo, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-logo-enabled"
/>
<Label htmlFor="logo-enabled" className="cursor-pointer flex-1 text-sm">
Afficher le logo
</Label>
</div>
{logo.enabled && (
<>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={logo.position} onValueChange={(value) => setLogo({ ...logo, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="north_east">Haut droit</SelectItem>
<SelectItem value="south_west">Bas gauche</SelectItem>
<SelectItem value="south_east">Bas droit</SelectItem>
<SelectItem value="center">Centre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Taille</Label>
<Select value={logo.size} onValueChange={(value) => setLogo({ ...logo, size: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Petit</SelectItem>
<SelectItem value="medium">Moyen</SelectItem>
<SelectItem value="large">Grand</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-sm">Opacité</Label>
<span className="text-sm text-muted-foreground">{logo.opacity}%</span>
</div>
<Slider
value={[logo.opacity]}
onValueChange={(value) => setLogo({ ...logo, opacity: value[0] })}
min={0}
max={100}
step={5}
className="touch-manipulation"
data-testid="slider-logo-opacity"
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import MediaUpload from "@/components/media-upload";
export default function MediaMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Reduced padding for better space usage */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Médiathèque</h1>
<p className="text-sm text-muted-foreground">
Gérez vos images et vidéos pour vos publications
</p>
</div>
<MediaUpload />
</div>
</main>
</div>
);
}
+790
View File
@@ -0,0 +1,790 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
import { useDropzone } from "react-dropzone";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker";
function SortableMediaItem({
media,
index,
isSelected,
onToggle
}: {
media: Media;
index: number;
isSelected: boolean;
onToggle: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: media.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isVideo = media.type === 'video';
return (
<div
ref={setNodeRef}
style={style}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
isSelected
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-muted-foreground'
}`}
>
<button
onClick={onToggle}
className="w-full h-full min-h-[44px]"
data-testid={`button-select-media-${media.id}`}
>
{isVideo ? (
<video
src={media.originalUrl}
className="w-full h-full object-cover"
muted
playsInline
preload="metadata"
/>
) : (
<img
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
/>
)}
</button>
{isSelected && (
<>
<div className="absolute top-2 right-2 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-bold shadow-lg">
{index + 1}
</div>
<div
{...attributes}
{...listeners}
className="absolute top-2 left-2 w-10 h-10 bg-black/70 rounded-full flex items-center justify-center cursor-move hover:bg-black/90 transition-colors touch-manipulation"
>
<GripVertical className="w-5 h-5 text-white" />
</div>
</>
)}
</div>
);
}
export default function NewPostMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null);
const [productInfo, setProductInfo] = useState('');
const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [postText, setPostText] = useState('');
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const mediaList = useMemo(() => {
return [...allMedia]
.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
})
.slice(0, 12);
}, [allMedia]);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'],
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error("Upload failed");
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedMedia(prev => {
if (prev.length >= 10) {
toast({
title: "Limite atteinte",
description: "Maximum 10 photos par publication",
variant: "destructive",
});
return prev;
}
return [...prev, data.id];
});
toast({
title: "Succès",
description: "Photo téléchargée avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de télécharger la photo",
variant: "destructive",
});
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
uploadMutation.mutate(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
"video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"],
},
maxSize: 52428800,
noClick: true,
noKeyboard: true,
});
const generateTextMutation = useMutation({
mutationFn: async (productInfoText: string) => {
const productInfo = {
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText,
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
};
const response = await apiRequest('POST', '/api/ai/generate', productInfo);
return response.json();
},
onSuccess: (data: any) => {
const variants = data.variants || [];
setGeneratedVariants(variants);
toast({
title: "Texte généré",
description: `${variants.length} variations créées`,
});
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de générer le texte",
variant: "destructive",
});
},
});
const createPostMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('POST', '/api/posts', data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({
title: "Publication créée",
description: "La publication a été créée avec succès",
});
navigate('/');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de créer la publication",
variant: "destructive",
});
},
});
const handleGenerateText = () => {
if (!productInfo.trim()) {
toast({
title: "Information manquante",
description: "Veuillez saisir les informations du produit",
variant: "destructive",
});
return;
}
generateTextMutation.mutate(productInfo);
};
const handleCreatePost = () => {
if (!postText.trim()) {
toast({
title: "Contenu requis",
description: "Veuillez saisir le contenu de la publication",
variant: "destructive",
});
return;
}
if (selectedPages.length === 0) {
toast({
title: "Page requise",
description: "Veuillez sélectionner au moins une page",
variant: "destructive",
});
return;
}
if ((postType === 'story' || postType === 'both') && selectedMedia.length === 0) {
toast({
title: "Média requis",
description: "Les stories nécessitent au moins une image ou vidéo",
variant: "destructive",
});
return;
}
createPostMutation.mutate({
content: postText,
scheduledFor: scheduledDate ? scheduledDate.toISOString() : undefined,
mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined,
pageIds: selectedPages,
postType,
});
};
const handleUseVariant = (text: string) => {
setPostText(text);
toast({
title: "Texte sélectionné",
description: "Le texte a été ajouté à votre publication",
});
};
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
let fileToUpload = file;
if (!file.name || file.name === 'blob' || file.name === '') {
const extension = file.type.split('/')[1] || 'jpg';
const newFileName = `camera-${Date.now()}.${extension}`;
fileToUpload = new File([file], newFileName, { type: file.type });
}
if (file.size > 52428800) {
toast({
title: "Fichier trop volumineux",
description: `La taille maximale est de 50 MB. Votre fichier fait ${(file.size / 1024 / 1024).toFixed(2)} MB`,
variant: "destructive",
});
e.target.value = '';
return;
}
uploadMutation.mutate(fileToUpload);
e.target.value = '';
}
};
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setSelectedMedia((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 space-y-6 pb-24">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Nouvelle publication</h1>
<p className="text-sm text-muted-foreground">
Créez et planifiez une nouvelle publication
</p>
</div>
{/* MOBILE: Single column layout */}
<div className="space-y-6">
{/* Média Card */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">Média</CardTitle>
<CardDescription className="text-xs">Sélectionnez ou uploadez</CardDescription>
</div>
<div className="flex gap-2">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
size="sm"
variant="outline"
className="min-h-[44px]"
data-testid="button-camera-capture"
>
<Camera className="w-5 h-5" />
</Button>
<Button
onClick={open}
disabled={uploadMutation.isPending}
size="sm"
variant="outline"
className="min-h-[44px]"
data-testid="button-upload-new-media"
>
<Upload className="w-5 h-5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<input
ref={cameraInputRef}
type="file"
accept="image/*,video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
<input {...getInputProps()} />
{mediaList.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg">
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground mb-4 text-sm">
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
</p>
<div className="flex flex-col gap-3">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
size="lg"
variant="outline"
className="w-full min-h-[48px]"
data-testid="button-camera-first"
>
<Camera className="w-5 h-5 mr-2" />
Prendre une photo
</Button>
<Button
onClick={open}
disabled={uploadMutation.isPending}
size="lg"
className="w-full min-h-[48px]"
data-testid="button-upload-first-media"
>
<Upload className="w-5 h-5 mr-2" />
Uploader une photo
</Button>
</div>
</div>
) : (
<>
{selectedMedia.length > 0 && (
<div className="mb-4">
<div className="text-sm font-medium text-muted-foreground mb-3">
Photos sélectionnées ({selectedMedia.length}/10)
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedMedia}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-2 gap-3 p-4 bg-accent/20 rounded-lg border border-accent">
{selectedMedia.map((mediaId, index) => {
const media = mediaList.find(m => m.id === mediaId);
if (!media) return null;
return (
<SortableMediaItem
key={media.id}
media={media}
index={index}
isSelected={true}
onToggle={() => {
setSelectedMedia(prev => prev.filter(id => id !== media.id));
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
<div className="text-sm font-medium text-muted-foreground mb-3">
Toutes les photos ({mediaList.length})
</div>
<div className="max-h-[400px] overflow-y-auto">
<div className="grid grid-cols-2 gap-3">
{mediaList.map((media) => {
const isSelected = selectedMedia.includes(media.id);
const isVideo = media.type === 'video';
return (
<button
key={media.id}
onClick={() => {
if (isSelected) {
setSelectedMedia(prev => prev.filter(id => id !== media.id));
} else if (selectedMedia.length < 10) {
setSelectedMedia(prev => [...prev, media.id]);
} else {
toast({
title: "Limite atteinte",
description: "Maximum 10 photos par publication",
variant: "destructive",
});
}
}}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
isSelected
? 'border-primary ring-2 ring-primary opacity-50'
: 'border-transparent hover:border-muted-foreground'
}`}
data-testid={`button-select-media-${media.id}`}
>
{isVideo ? (
<video
src={media.originalUrl}
className="w-full h-full object-cover"
muted
playsInline
preload="metadata"
/>
) : (
<img
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
/>
)}
{isSelected && (
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
<div className="text-xs font-semibold text-primary bg-white px-2 py-1 rounded">Sélectionnée</div>
</div>
)}
</button>
);
})}
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Contenu Card */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Contenu</CardTitle>
<CardDescription className="text-xs">Informations du produit ou message</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="productInfo" className="text-sm">Informations du produit</Label>
<Textarea
id="productInfo"
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
rows={5}
className="mt-2"
data-testid="input-product-info"
/>
</div>
<Button
onClick={handleGenerateText}
disabled={generateTextMutation.isPending}
className="w-full min-h-[48px]"
size="lg"
data-testid="button-generate-text"
>
<Sparkles className="w-5 h-5 mr-2" />
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'}
</Button>
</CardContent>
</Card>
{/* Variations générées */}
{generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Variations générées par l'IA</CardTitle>
<CardDescription className="text-xs">Cliquez sur "Utiliser"</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{generatedVariants.map((variant, index) => (
<div
key={index}
className="p-4 rounded-lg border bg-card"
>
<div className="space-y-3">
<div className="text-xs font-semibold text-muted-foreground">
{variant.variant || `Version ${index + 1}`}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{variant.text}
</p>
<Button
size="sm"
onClick={() => handleUseVariant(variant.text)}
className="w-full min-h-[44px]"
data-testid={`button-use-variant-${index}`}
>
Utiliser
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Texte de la publication */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Texte de la publication</CardTitle>
<CardDescription className="text-xs">Écrivez ou générez le texte</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={postText}
onChange={(e) => setPostText(e.target.value)}
placeholder="Écrivez votre texte ici ou générez-le avec l'IA..."
rows={6}
data-testid="textarea-post-text"
/>
</CardContent>
</Card>
{/* Pages cibles */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Pages cibles</CardTitle>
<CardDescription className="text-xs">Sélectionnez les pages</CardDescription>
</CardHeader>
<CardContent>
{pages.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-3 text-sm">Aucune page connectée</p>
<Button
variant="link"
onClick={() => navigate('/pages')}
className="min-h-[44px]"
data-testid="link-add-pages"
>
Ajouter des pages
</Button>
</div>
) : (
<div className="space-y-4">
{pages.map((page) => (
<div key={page.id} className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-accent touch-manipulation">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
className="w-5 h-5"
data-testid={`checkbox-page-${page.id}`}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-relaxed flex-1 cursor-pointer"
>
{page.pageName} ({page.platform})
</label>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Type de publication */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Type de publication</CardTitle>
<CardDescription className="text-xs">Choisissez où publier</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Label htmlFor="postType">Format</Label>
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
<SelectTrigger id="postType" className="min-h-[48px]" data-testid="select-post-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem>
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem>
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{postType === 'feed' && 'Publication classique dans le fil d\'actualité'}
{postType === 'story' && 'Story éphémère (24h) - Nécessite un média'}
{postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'}
</p>
</CardContent>
</Card>
{/* Planification */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Planification</CardTitle>
<CardDescription className="text-xs">Programmez (optionnel)</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Label>Date et heure</Label>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={scheduledPosts
.filter(post => post.scheduledAt)
.map(post => new Date(post.scheduledAt!))}
placeholder="Publier immédiatement"
/>
<p className="text-xs text-muted-foreground">
Sélectionnez une date pour planifier, sinon publication immédiate
</p>
</CardContent>
</Card>
{/* Bouton Publier - Fixed au bottom */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
<Button
onClick={() => setPreviewModalOpen(true)}
disabled={createPostMutation.isPending}
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg min-h-[52px]"
size="lg"
data-testid="button-preview-post"
>
<Send className="w-5 h-5 mr-2" />
Prévisualiser et publier
</Button>
</div>
</div>
</div>
</main>
<Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
<div className="flex flex-col items-center justify-center py-8">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
Upload en cours...
</h3>
<p className="text-sm text-muted-foreground text-center">
Votre image est en cours de téléchargement et de traitement.
<br />
Veuillez patienter.
</p>
</div>
</DialogContent>
</Dialog>
<PreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
postText={postText}
selectedMedia={selectedMedia}
mediaList={mediaList}
onPublish={handleCreatePost}
isPublishing={createPostMutation.isPending}
/>
</div>
);
}
+520
View File
@@ -0,0 +1,520 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
if (!expiresAt) {
return {
status: 'unknown' as const,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date();
const expiration = new Date(expiresAt);
const diffTime = expiration.getTime() - now.getTime();
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) {
return {
status: 'expired' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: 'Expiré'
};
} else if (daysLeft <= 7) {
return {
status: 'urgent' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
};
} else if (daysLeft <= 15) {
return {
status: 'warning' as const,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
daysLeft,
message: `${daysLeft} jours restants`
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
}
export default function PagesManagementMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const deleteMutation = useMutation({
mutationFn: (pageId: string) =>
apiRequest('DELETE', `/api/pages/${pageId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page supprimée",
description: "La page a été retirée avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer la page",
variant: "destructive",
});
},
});
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout with single column */}
<div className="p-4 space-y-6 pb-24">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<p className="text-sm text-muted-foreground">
Connectez et gérez vos pages Facebook et Instagram
</p>
</div>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
{/* MOBILE: Stack buttons vertically */}
<div className="flex flex-col gap-3">
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/debug/accesstoken/', '_blank')}
className="w-full min-h-[48px]"
data-testid="button-debug-token"
>
<Bug className="w-5 h-5 mr-2" />
Débogueur de jeton
</Button>
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/explorer', '_blank')}
className="w-full min-h-[48px]"
data-testid="button-graph-explorer"
>
<Code className="w-5 h-5 mr-2" />
Graph Explorer
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 bg-card rounded-2xl border border-border/50 animate-pulse" />
))}
</div>
) : pages.length === 0 ? (
<Card className="border-dashed rounded-2xl border-border/50 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Plus className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2 text-center">Aucune page connectée</h3>
<p className="text-muted-foreground text-center mb-4 text-sm px-4">
Commencez par connecter vos pages Facebook et Instagram
</p>
<Button onClick={() => setDialogOpen(true)} className="min-h-[48px]" data-testid="button-add-first-page">
<Plus className="w-5 h-5 mr-2" />
Ajouter une page
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{pages.map((page) => (
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}>
<CardHeader className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
{page.platform === 'facebook' ? (
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Facebook className="w-6 h-6 text-white" />
</div>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Instagram className="w-6 h-6 text-white" />
</div>
)}
<div className="min-w-0">
<CardTitle className="text-base truncate">{page.pageName}</CardTitle>
<CardDescription className="capitalize text-xs">
{page.platform}
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-5 pt-0">
<div className="text-xs text-muted-foreground break-all">
ID: {page.pageId}
</div>
{/* Token expiration status */}
{(() => {
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
)}
</div>
);
})()}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
<Button
variant="destructive"
className="flex-1 min-h-[44px]"
onClick={() => deleteMutation.mutate(page.id)}
disabled={deleteMutation.isPending}
data-testid={`button-delete-page-${page.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Fixed Add Button */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
<Button
onClick={() => setDialogOpen(true)}
className="w-full min-h-[52px]"
data-testid="button-add-page"
>
<Plus className="w-5 h-5 mr-2" />
Ajouter une page
</Button>
</div>
</div>
</main>
</div>
);
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState('');
const [pageId, setPageId] = useState('');
const [accessToken, setAccessToken] = useState('');
const { toast } = useToast();
const addMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('POST', '/api/pages', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page ajoutée",
description: "La page a été connectée avec succès",
});
onOpenChange(false);
setPlatform('facebook');
setPageName('');
setPageId('');
setAccessToken('');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible d'ajouter la page",
variant: "destructive",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addMutation.mutate({
platform,
pageName,
pageId,
accessToken,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Ajouter une page</DialogTitle>
<DialogDescription className="text-xs">
Connectez une page Facebook ou Instagram pour commencer à publier
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="platform" className="text-sm">Plateforme</Label>
<Select value={platform} onValueChange={(v) => setPlatform(v as any)}>
<SelectTrigger className="min-h-[48px]" data-testid="select-platform">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="facebook">Facebook</SelectItem>
<SelectItem value="instagram">Instagram</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pageName" className="text-sm">Nom de la page</Label>
<Input
id="pageName"
value={pageName}
onChange={(e) => setPageName(e.target.value)}
placeholder="Mon entreprise"
required
className="min-h-[48px]"
data-testid="input-page-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pageId" className="text-sm">ID de la page</Label>
<Input
id="pageId"
value={pageId}
onChange={(e) => setPageId(e.target.value)}
placeholder="123456789"
required
className="min-h-[48px]"
data-testid="input-page-id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accessToken" className="text-sm">Jeton d'accès</Label>
<Input
id="accessToken"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAxxxxxxxxxxxxx"
required
className="min-h-[48px]"
data-testid="input-access-token"
/>
<p className="text-xs text-muted-foreground">
Obtenez votre jeton depuis{' '}
<a
href="https://developers.facebook.com/tools/explorer"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Graph API Explorer
</a>
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
type="submit"
disabled={addMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-submit-page"
>
{addMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Ajout...
</>
) : (
'Ajouter'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="w-full min-h-[48px]"
data-testid="button-cancel"
>
Annuler
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState('');
const { toast } = useToast();
const editMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('PUT', `/api/pages/${page?.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page modifiée",
description: "Le jeton d'accès a été mis à jour avec succès",
});
onOpenChange(false);
setAccessToken('');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de modifier la page",
variant: "destructive",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!page) return;
editMutation.mutate({
accessToken,
});
};
if (!page) return null;
return (
<Dialog open={!!page} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Modifier le jeton d'accès</DialogTitle>
<DialogDescription className="text-xs">
Mettez à jour le jeton d'accès pour {page.pageName}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="editAccessToken" className="text-sm">Nouveau jeton d'accès</Label>
<Input
id="editAccessToken"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAxxxxxxxxxxxxx"
required
className="min-h-[48px]"
data-testid="input-edit-access-token"
/>
<p className="text-xs text-muted-foreground">
Obtenez un nouveau jeton depuis{' '}
<a
href="https://developers.facebook.com/tools/explorer"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Graph API Explorer
</a>
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
type="submit"
disabled={editMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-submit-edit"
>
{editMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Modification...
</>
) : (
'Modifier'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="w-full min-h-[48px]"
data-testid="button-cancel-edit"
>
Annuler
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+525
View File
@@ -0,0 +1,525 @@
import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox";
export default function SettingsMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState(true);
const [autoPublish, setAutoPublish] = useState(true);
const [cloudName, setCloudName] = useState("");
const [apiKey, setApiKey] = useState("");
const [apiSecret, setApiSecret] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const { toast } = useToast();
const { data: cloudinaryConfig } = useQuery({
queryKey: ['/api/cloudinary/config'],
});
const { data: openrouterConfig } = useQuery({
queryKey: ['/api/openrouter/config'],
});
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'],
});
useEffect(() => {
if (cloudinaryConfig) {
setCloudName((cloudinaryConfig as any).cloudName || "");
setApiKey((cloudinaryConfig as any).apiKey || "");
if ((cloudinaryConfig as any).logoPublicId) {
const cloudName = (cloudinaryConfig as any).cloudName;
const logoPublicId = (cloudinaryConfig as any).logoPublicId;
setLogoPreview(`https://res.cloudinary.com/${cloudName}/image/upload/${logoPublicId}`);
}
}
}, [cloudinaryConfig]);
useEffect(() => {
if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
}
}, [openrouterConfig]);
const hasExistingConfig = !!openrouterConfig;
const saveCloudinaryMutation = useMutation({
mutationFn: () => {
const payload: any = {
cloudName,
};
if (apiKey && apiKey.trim() !== "") {
payload.apiKey = apiKey;
}
if (apiSecret && apiSecret.trim() !== "") {
payload.apiSecret = apiSecret;
}
return apiRequest('POST', '/api/cloudinary/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({
title: "Configuration sauvegardée",
description: "Vos paramètres Cloudinary ont été enregistrés",
});
setApiKey("");
setApiSecret("");
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration",
variant: "destructive",
});
},
});
const saveOpenrouterMutation = useMutation({
mutationFn: () => {
const payload: any = {
model: openrouterModel,
systemPrompt: openrouterSystemPrompt,
};
if (openrouterApiKey && openrouterApiKey.trim() !== "") {
payload.apiKey = openrouterApiKey;
}
return apiRequest('POST', '/api/openrouter/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/openrouter/config'] });
toast({
title: "Configuration sauvegardée",
description: "Vos paramètres OpenRouter ont été enregistrés",
});
setOpenrouterApiKey("");
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration OpenRouter",
variant: "destructive",
});
},
});
const uploadLogoMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('logo', file);
const response = await fetch('/api/cloudinary/logo', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({
title: "Logo uploadé",
description: "Votre logo a été enregistré avec succès",
});
setLogoFile(null);
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message || "Impossible d'uploader le logo",
variant: "destructive",
});
},
});
const deleteLogoMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
setLogoPreview(null);
setLogoFile(null);
toast({
title: "Logo supprimé",
description: "Votre logo a été supprimé avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer le logo",
variant: "destructive",
});
},
});
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: "Erreur",
description: "Veuillez sélectionner une image",
variant: "destructive",
});
return;
}
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
<p className="text-sm text-muted-foreground">
Configurez votre application
</p>
</div>
{/* Notifications */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="w-5 h-5" />
Notifications
</CardTitle>
<CardDescription className="text-xs">
Gérez vos préférences de notification
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="space-y-0.5 flex-1 pr-3">
<Label htmlFor="notifications" className="text-sm">Notifications activées</Label>
<p className="text-xs text-muted-foreground">
Recevez des notifications pour les publications
</p>
</div>
<Switch
id="notifications"
checked={notifications}
onCheckedChange={setNotifications}
data-testid="switch-notifications"
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="space-y-0.5 flex-1 pr-3">
<Label htmlFor="autoPublish" className="text-sm">Publication automatique</Label>
<p className="text-xs text-muted-foreground">
Publier automatiquement à l'heure programmée
</p>
</div>
<Switch
id="autoPublish"
checked={autoPublish}
onCheckedChange={setAutoPublish}
data-testid="switch-auto-publish"
/>
</div>
</CardContent>
</Card>
{/* OpenRouter */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="w-5 h-5" />
Configuration OpenRouter (IA)
</CardTitle>
<CardDescription className="text-xs">
Configurez OpenRouter pour la génération de texte avec l'IA
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<div className="space-y-2">
<Label htmlFor="openrouterApiKey" className="text-sm">Clé API OpenRouter</Label>
<Input
id="openrouterApiKey"
type="password"
value={openrouterApiKey}
onChange={(e) => setOpenrouterApiKey(e.target.value)}
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
className="min-h-[48px]"
data-testid="input-openrouter-api-key"
/>
<p className="text-xs text-muted-foreground">
{hasExistingConfig ? (
<>
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
</>
) : (
<>
Obtenez votre clé API sur{" "}
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="underline">
openrouter.ai
</a>
</>
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouterModel" className="text-sm">Modèle d'IA</Label>
<ModelCombobox
models={(openrouterModels as any)?.data || []}
value={openrouterModel}
onValueChange={setOpenrouterModel}
placeholder="Sélectionner un modèle"
isLoading={modelsLoading}
className="w-full"
testId="select-openrouter-model"
/>
<p className="text-xs text-muted-foreground">
Choisissez le modèle d'IA pour générer vos publications
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouterSystemPrompt" className="text-sm">Prompt système</Label>
<Textarea
id="openrouterSystemPrompt"
value={openrouterSystemPrompt}
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
placeholder="Instructions pour l'IA..."
rows={5}
data-testid="textarea-openrouter-system-prompt"
/>
<p className="text-xs text-muted-foreground">
Définissez les instructions que l'IA doit suivre pour générer vos textes
</p>
</div>
<Button
onClick={() => saveOpenrouterMutation.mutate()}
disabled={
saveOpenrouterMutation.isPending ||
!openrouterModel ||
!openrouterSystemPrompt ||
(!hasExistingConfig && !openrouterApiKey)
}
data-testid="button-save-openrouter"
className="w-full min-h-[48px]"
>
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
</Button>
</CardContent>
</Card>
{/* Cloudinary */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="w-5 h-5" />
Configuration Cloudinary
</CardTitle>
<CardDescription className="text-xs">
Configurez Cloudinary pour le stockage d'images dans le cloud
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<div className="space-y-2">
<Label htmlFor="cloudName" className="text-sm">Cloud Name</Label>
<Input
id="cloudName"
type="text"
value={cloudName}
onChange={(e) => setCloudName(e.target.value)}
placeholder="mon-cloud-name"
className="min-h-[48px]"
data-testid="input-cloud-name"
/>
<p className="text-xs text-muted-foreground">
Trouvez-le dans votre tableau de bord Cloudinary
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey" className="text-sm">API Key</Label>
<Input
id="apiKey"
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="123456789012345"
className="min-h-[48px]"
data-testid="input-api-key"
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiSecret" className="text-sm">API Secret</Label>
<Input
id="apiSecret"
type="password"
value={apiSecret}
onChange={(e) => setApiSecret(e.target.value)}
placeholder="••••••••••••••••"
className="min-h-[48px]"
data-testid="input-api-secret"
/>
<p className="text-xs text-muted-foreground">
Votre API Secret restera sécurisé et ne sera jamais exposé
</p>
</div>
<Button
onClick={() => saveCloudinaryMutation.mutate()}
disabled={saveCloudinaryMutation.isPending || !cloudName || !apiKey || !apiSecret}
data-testid="button-save-cloudinary"
className="w-full min-h-[48px]"
>
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
</Button>
</CardContent>
</Card>
{/* Logo */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Image className="w-5 h-5" />
Logo de l'entreprise
</CardTitle>
<CardDescription className="text-xs">
Uploadez votre logo pour l'ajouter aux images éditées
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
{logoPreview ? (
<div className="space-y-4">
<div className="relative w-full max-w-xs mx-auto">
<img
src={logoPreview}
alt="Logo"
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
/>
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-10 w-10"
onClick={() => deleteLogoMutation.mutate()}
disabled={deleteLogoMutation.isPending}
data-testid="button-delete-logo"
>
<X className="h-5 w-5" />
</Button>
</div>
<div>
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
<Button
variant="outline"
className="w-full min-h-[48px]"
disabled={uploadLogoMutation.isPending}
data-testid="button-replace-logo"
onClick={() => document.getElementById('logo-upload-replace')?.click()}
>
<Upload className="w-5 h-5 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
</Button>
</Label>
<input
id="logo-upload-replace"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucun logo uploadé. Choisissez une image PNG ou JPG.
</p>
<Label htmlFor="logo-upload" className="cursor-pointer">
<Button
variant="outline"
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
className="min-h-[48px]"
data-testid="button-upload-logo"
onClick={() => document.getElementById('logo-upload')?.click()}
>
<Upload className="w-5 h-5 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
</Button>
</Label>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
{!cloudinaryConfig && (
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
Veuillez d'abord configurer Cloudinary ci-dessus
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Database, Download, Play } from "lucide-react";
export default function SqlAdminMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
const [sqlQuery, setSqlQuery] = useState("");
const [queryResult, setQueryResult] = useState<any>(null);
const { data: tablesData } = useQuery({
queryKey: ["/api/sql/tables"],
});
const executeSqlMutation = useMutation({
mutationFn: async (query: string) => {
const res = await apiRequest("POST", "/api/sql/execute", { query });
return await res.json();
},
onSuccess: (data) => {
setQueryResult(data);
if (data.success) {
toast({
title: "Requête exécutée",
description: "La requête SQL a été exécutée avec succès",
});
}
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur SQL",
description: error.message || "Erreur lors de l'exécution de la requête",
});
},
});
const handleExecuteQuery = () => {
if (!sqlQuery.trim()) {
toast({
variant: "destructive",
title: "Erreur",
description: "Veuillez entrer une requête SQL",
});
return;
}
executeSqlMutation.mutate(sqlQuery);
};
const handleDownloadResult = () => {
if (!queryResult?.result) return;
const json = JSON.stringify(queryResult.result, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `sql-result-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const insertTable = (tableName: string) => {
setSqlQuery(`SELECT * FROM ${tableName} LIMIT 10;`);
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Database className="w-7 h-7" />
Administration SQL
</h1>
<p className="text-sm text-muted-foreground">
Exécutez des requêtes SQL et gérez votre base de données
</p>
</div>
{/* Tables disponibles */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Tables disponibles</CardTitle>
<CardDescription className="text-xs">
Cliquez pour insérer dans la requête
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
<div className="grid grid-cols-2 gap-2">
{(tablesData as any)?.tables?.map((table: any) => (
<Button
key={table.tablename}
variant="outline"
className="justify-start text-left min-h-[44px] text-xs"
onClick={() => insertTable(table.tablename)}
data-testid={`button-table-${table.tablename}`}
>
<Database className="w-4 h-4 mr-2 flex-shrink-0" />
<span className="truncate">{table.tablename}</span>
</Button>
))}
</div>
</CardContent>
</Card>
{/* Éditeur SQL */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Éditeur SQL</CardTitle>
<CardDescription className="text-xs">
Entrez votre requête SQL ci-dessous
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<Textarea
placeholder="SELECT * FROM users LIMIT 10;"
value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)}
className="font-mono min-h-[150px] text-sm"
data-testid="textarea-sql-query"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleExecuteQuery}
disabled={executeSqlMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-execute-sql"
>
<Play className="w-5 h-5 mr-2" />
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
</Button>
<Button
variant="outline"
onClick={() => setSqlQuery("")}
className="w-full min-h-[48px]"
data-testid="button-clear-sql"
>
Effacer
</Button>
</div>
</CardContent>
</Card>
{/* Résultats */}
{queryResult && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<div className="flex flex-col gap-3">
<div>
<CardTitle className="text-base">Résultats</CardTitle>
<CardDescription className="text-xs">
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"}
</CardDescription>
</div>
{queryResult.success && queryResult.result && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadResult}
className="w-full min-h-[44px]"
data-testid="button-download-result"
>
<Download className="w-4 h-4 mr-2" />
Télécharger JSON
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-5 pt-0">
{queryResult.success ? (
<div className="overflow-x-auto">
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
<div className="space-y-3">
{queryResult.result.map((row: any, idx: number) => (
<div key={idx} className="p-3 rounded-lg border bg-card space-y-2">
<div className="text-xs font-semibold text-muted-foreground">Ligne {idx + 1}</div>
{Object.entries(row).map(([key, value]: [string, any]) => (
<div key={key} className="flex flex-col gap-1">
<div className="text-xs font-medium">{key}</div>
<div className="font-mono text-xs text-muted-foreground break-all">
{value === null ? (
<span className="italic">null</span>
) : typeof value === 'object' ? (
JSON.stringify(value)
) : (
String(value)
)}
</div>
</div>
))}
</div>
))}
</div>
) : (
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96 text-xs">
<code>{JSON.stringify(queryResult.result, null, 2)}</code>
</pre>
)}
</div>
) : (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg">
<p className="font-semibold text-sm">Erreur :</p>
<p className="font-mono text-xs mt-2 break-all">{queryResult.error}</p>
</div>
)}
</CardContent>
</Card>
)}
</div>
</main>
</div>
);
}
+589
View File
@@ -0,0 +1,589 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, Settings } from "lucide-react";
type UserData = {
id: string;
username: string;
role: "admin" | "user";
};
type SocialPage = {
id: string;
pageName: string;
platform: string;
userId: string;
};
type UserPagePermission = {
id: string;
userId: string;
pageId: string;
};
export default function UsersAdminMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserData | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editPassword, setEditPassword] = useState("");
const [editRole, setEditRole] = useState<"admin" | "user">("user");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const [permissionsUser, setPermissionsUser] = useState<UserData | null>(null);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const { data: users, isLoading } = useQuery<UserData[]>({
queryKey: ["/api/users"],
});
const { data: allPages } = useQuery<SocialPage[]>({
queryKey: ["/api/pages"],
});
const { data: userPermissions } = useQuery<UserPagePermission[]>({
queryKey: ["/api/users", permissionsUser?.id, "page-permissions"],
enabled: !!permissionsUser && permissionsDialogOpen,
});
const createUserMutation = useMutation({
mutationFn: async (userData: { username: string; password: string; role: string }) => {
const res = await apiRequest("POST", "/api/users", userData);
return await res.json();
},
onSuccess: (data) => {
toast({
title: "Utilisateur créé",
description: `L'utilisateur ${data.username} a été créé avec succès`,
});
setUsername("");
setPassword("");
setRole("user");
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la création de l'utilisateur",
});
},
});
const updateUserMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const res = await apiRequest("PATCH", `/api/users/${id}`, data);
return await res.json();
},
onSuccess: (data) => {
toast({
title: "Utilisateur modifié",
description: `L'utilisateur ${data.username} a été modifié avec succès`,
});
setEditDialogOpen(false);
setEditingUser(null);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la modification de l'utilisateur",
});
},
});
const deleteUserMutation = useMutation({
mutationFn: async (id: string) => {
const res = await apiRequest("DELETE", `/api/users/${id}`, {});
return await res.json();
},
onSuccess: () => {
toast({
title: "Utilisateur supprimé",
description: "L'utilisateur a été supprimé avec succès",
});
setDeleteDialogOpen(false);
setUserToDelete(null);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la suppression de l'utilisateur",
});
},
});
const updatePermissionsMutation = useMutation({
mutationFn: async ({ userId, pageIds }: { userId: string; pageIds: string[] }) => {
const res = await apiRequest("POST", `/api/users/${userId}/page-permissions`, { pageIds });
return await res.json();
},
onSuccess: () => {
toast({
title: "Permissions mises à jour",
description: "Les permissions de l'utilisateur ont été mises à jour avec succès",
});
setPermissionsDialogOpen(false);
setPermissionsUser(null);
setSelectedPageIds([]);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la mise à jour des permissions",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
toast({
variant: "destructive",
title: "Erreur",
description: "Veuillez remplir tous les champs",
});
return;
}
if (password.length < 4) {
toast({
variant: "destructive",
title: "Erreur",
description: "Le mot de passe doit contenir au moins 4 caractères",
});
return;
}
createUserMutation.mutate({ username, password, role });
};
const handleEditClick = (user: UserData) => {
setEditingUser(user);
setEditUsername(user.username);
setEditPassword("");
setEditRole(user.role);
setEditDialogOpen(true);
};
const handleEditSubmit = () => {
if (!editingUser) return;
const updateData: any = {};
if (editUsername !== editingUser.username) {
updateData.username = editUsername;
}
if (editPassword) {
updateData.password = editPassword;
}
if (editRole !== editingUser.role) {
updateData.role = editRole;
}
if (Object.keys(updateData).length === 0) {
toast({
variant: "destructive",
title: "Erreur",
description: "Aucune modification détectée",
});
return;
}
updateUserMutation.mutate({ id: editingUser.id, data: updateData });
};
const handleDeleteClick = (user: UserData) => {
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = () => {
if (userToDelete) {
deleteUserMutation.mutate(userToDelete.id);
}
};
const handlePermissionsClick = (user: UserData) => {
setPermissionsUser(user);
setPermissionsDialogOpen(true);
};
useEffect(() => {
if (userPermissions) {
setSelectedPageIds(userPermissions.map(p => p.pageId));
}
}, [userPermissions]);
const handlePermissionsSubmit = () => {
if (permissionsUser) {
updatePermissionsMutation.mutate({
userId: permissionsUser.id,
pageIds: selectedPageIds,
});
}
};
const togglePagePermission = (pageId: string) => {
setSelectedPageIds(prev => {
if (prev.includes(pageId)) {
return prev.filter(id => id !== pageId);
} else {
return [...prev, pageId];
}
});
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Users className="w-7 h-7" />
Gestion des utilisateurs
</h1>
<p className="text-sm text-muted-foreground">
Créez et gérez les utilisateurs de l'application
</p>
</div>
{/* Formulaire de création */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<UserPlus className="w-5 h-5" />
Créer un utilisateur
</CardTitle>
<CardDescription className="text-xs">
Ajouter un nouvel utilisateur
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">Nom d'utilisateur</Label>
<Input
id="username"
type="text"
placeholder="utilisateur123"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="min-h-[48px]"
data-testid="input-new-username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">Mot de passe</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="min-h-[48px]"
data-testid="input-new-password"
/>
<p className="text-xs text-muted-foreground">
Minimum 4 caractères
</p>
</div>
<div className="space-y-2">
<Label htmlFor="role" className="text-sm">Rôle</Label>
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
<SelectTrigger id="role" className="min-h-[48px]" data-testid="select-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="submit"
className="w-full min-h-[48px]"
disabled={createUserMutation.isPending}
data-testid="button-create-user"
>
{createUserMutation.isPending ? "Création..." : "Créer"}
</Button>
</form>
</CardContent>
</Card>
{/* Liste des utilisateurs */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Liste des utilisateurs</CardTitle>
<CardDescription className="text-xs">
{users?.length || 0} utilisateur(s) enregistré(s)
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-4 text-sm">Chargement...</p>
</div>
) : users && users.length > 0 ? (
<div className="space-y-3">
{users.map((user) => (
<div key={user.id} className="p-4 rounded-lg border bg-card space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{user.role === "admin" ? (
<Shield className="w-5 h-5 text-primary flex-shrink-0" />
) : (
<UserIcon className="w-5 h-5 text-muted-foreground flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{user.username}</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="mt-1">
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
</Badge>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
{user.role === "user" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePermissionsClick(user)}
className="w-full min-h-[44px]"
data-testid={`button-permissions-user-${user.id}`}
>
<Settings className="w-4 h-4 mr-2" />
Permissions
</Button>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditClick(user)}
className="flex-1 min-h-[44px]"
data-testid={`button-edit-user-${user.id}`}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(user)}
className="flex-1 min-h-[44px]"
data-testid={`button-delete-user-${user.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground text-sm">Aucun utilisateur trouvé</p>
</div>
)}
</CardContent>
</Card>
</div>
</main>
{/* Dialog de modification */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Modifier l'utilisateur</DialogTitle>
<DialogDescription className="text-xs">
Modifiez les informations de l'utilisateur. Laissez le mot de passe vide pour ne pas le changer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-username" className="text-sm">Nom d'utilisateur</Label>
<Input
id="edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
className="min-h-[48px]"
data-testid="input-edit-username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-password" className="text-sm">Nouveau mot de passe (optionnel)</Label>
<Input
id="edit-password"
type="password"
placeholder="Laisser vide pour ne pas changer"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className="min-h-[48px]"
data-testid="input-edit-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role" className="text-sm">Rôle</Label>
<Select value={editRole} onValueChange={(value: "admin" | "user") => setEditRole(value)}>
<SelectTrigger id="edit-role" className="min-h-[48px]" data-testid="select-edit-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex-col gap-2">
<Button
onClick={handleEditSubmit}
disabled={updateUserMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-save-edit"
>
{updateUserMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button variant="outline" onClick={() => setEditDialogOpen(false)} className="w-full min-h-[48px]">
Annuler
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de confirmation de suppression */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="w-[95vw] max-w-md mx-auto">
<AlertDialogHeader>
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
<AlertDialogDescription className="text-xs">
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
Cette action est irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col gap-2">
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 w-full min-h-[48px]"
data-testid="button-confirm-delete"
>
{deleteUserMutation.isPending ? "Suppression..." : "Supprimer"}
</AlertDialogAction>
<AlertDialogCancel className="w-full min-h-[48px] mt-0">Annuler</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog de gestion des permissions */}
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
<DialogContent className="w-[95vw] max-w-2xl mx-auto max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription className="text-xs">
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
</DialogDescription>
</DialogHeader>
<div className="py-4">
{allPages && allPages.length > 0 ? (
<div className="space-y-3">
{allPages.map((page) => (
<div key={page.id} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-accent touch-manipulation">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={() => togglePagePermission(page.id)}
className="w-5 h-5"
data-testid={`checkbox-page-${page.id}`}
/>
<Label
htmlFor={`page-${page.id}`}
className="flex-1 cursor-pointer"
>
<div className="font-medium text-sm">{page.pageName}</div>
<div className="text-xs text-muted-foreground">
{page.platform === "facebook" ? "Facebook" : "Instagram"}
</div>
</Label>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
Aucune page disponible
</div>
)}
</div>
<DialogFooter className="flex-col gap-2">
<Button
onClick={handlePermissionsSubmit}
disabled={updatePermissionsMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-save-permissions"
>
{updatePermissionsMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button variant="outline" onClick={() => setPermissionsDialogOpen(false)} className="w-full min-h-[48px]">
Annuler
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}