Files
Socialflow/client/src/pages/mobile/image-editor.tsx
T
Claude ad97da0f50 feat: Ajouter versions mobiles complètes pour toutes les pages
Implémentation d'un système responsive robuste avec détection automatique
du type d'appareil et switch transparent entre versions desktop et mobile.

## Nouvelles fonctionnalités

### 1. Hook de détection mobile (useIsMobile)
- Détection via media query (max-width: 768px)
- Validation supplémentaire via user agent
- Support des changements dynamiques de taille d'écran
- Hook useDeviceType pour détection granulaire (mobile/tablet/desktop)

### 2. Composant ResponsiveRoute
- Switch automatique entre versions desktop/mobile
- Lazy loading des composants pour performances optimales
- Factory function createResponsiveRoute pour création facile
- Suspense avec fallback de chargement

### 3. Pages mobiles optimisées (10 pages)
**Pages créées :**
- dashboard.tsx - Tableau de bord
- new-post.tsx - Création de posts (interface tactile optimisée)
- calendar.tsx - Calendrier
- media.tsx - Bibliothèque de médias
- image-editor.tsx - Éditeur d'images
- pages.tsx - Gestion des pages sociales
- ai.tsx - Assistant IA (admin)
- settings.tsx - Paramètres (admin)
- users-admin.tsx - Gestion utilisateurs (admin)
- sql.tsx - Administration SQL (admin)

**Optimisations mobiles appliquées :**
- Layout en colonne unique (au lieu de multi-colonnes)
- Boutons tactiles : min-height 44-52px (Apple guidelines)
- Grilles adaptées (2 colonnes au lieu de 3 pour médias)
- Navigation simplifiée avec menu hamburger
- Dialogs en plein écran (95vw sur mobile)
- Tables converties en cards empilées
- Boutons d'action en colonne au lieu de ligne
- Padding et espacement optimisés pour petits écrans
- Typographie réduite (text-2xl au lieu de 3xl)
- Touch-manipulation sur zones interactives
- Bottom fixed buttons pour actions principales
- Inputs full-width avec min-height 48px

### 4. Routing automatique
- App.tsx modifié pour utiliser createResponsiveRoute
- Détection transparente du device
- Lazy loading de toutes les routes
- Aucun changement d'URL entre versions

## Performances

- Code splitting automatique (lazy loading)
- Seule la version nécessaire est chargée
- Cache des composants chargés
- Réduction de la taille du bundle initial

## Tests à effectuer

1. Rétrécir la fenêtre du navigateur < 768px
2. Utiliser les DevTools mobile
3. Tester sur un vrai appareil mobile
4. Vérifier que toutes les fonctionnalités marchent

Les utilisateurs bénéficient maintenant d'une expérience optimale sur mobile !
2025-11-29 10:59:41 +00:00

644 lines
26 KiB
TypeScript

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>
);
}