mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 05:58:12 +02:00
Refonte UI : design system, logo, shell responsive et pages simples
- Nouveaux tokens (indigo/corail, statuts, plateformes) et mode sombre - Logo et favicon Social Flow redessinés - AppShell partagé : sidebar groupée (desktop) + barre de navigation basse (mobile) - Suppression des pages mobiles dupliquées et des composants morts - Tableau de bord, connexion, 404, calendrier, médiathèque, IA, statistiques Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
This commit is contained in:
55 files changed
+1591
-6331
No files matched your search
@@ -4,7 +4,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, User, Lightbulb, History, Zap, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Bot, User, Sparkles, Loader2, Copy, RotateCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ModelCombobox } from "@/components/model-combobox";
|
||||
|
||||
@@ -90,33 +91,20 @@ export default function AiChat() {
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const copyVariant = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: "Texte copié", description: "Collez-le dans votre publication." });
|
||||
} catch {
|
||||
toast({ title: "Copie impossible", description: "Sélectionnez le texte manuellement.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col">
|
||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Assistant IA</h3>
|
||||
<p className="text-sm text-muted-foreground">Génération de contenu intelligent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMessages([messages[0]])}
|
||||
className="rounded-xl"
|
||||
data-testid="button-new-chat"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Nouveau chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-foreground">Modèle IA:</label>
|
||||
<div className="flex h-[calc(100dvh-15rem)] min-h-[480px] flex-col overflow-hidden rounded-xl border bg-card shadow-soft lg:h-[calc(100vh-13rem)]">
|
||||
<div className="flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm text-muted-foreground">Modèle</span>
|
||||
<ModelCombobox
|
||||
models={availableModels}
|
||||
value={selectedModel}
|
||||
@@ -124,78 +112,91 @@ export default function AiChat() {
|
||||
placeholder="Sélectionner un modèle"
|
||||
isLoading={modelsLoading}
|
||||
disabled={modelsLoading}
|
||||
className="w-[300px] rounded-xl"
|
||||
className="w-full sm:w-[300px]"
|
||||
testId="select-ai-model"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMessages([messages[0]])}
|
||||
data-testid="button-new-chat"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Nouvelle conversation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-muted/20">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto bg-muted/30 p-4 sm:p-6">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-4 chat-message ${message.role === "user" ? "justify-end" : ""}`}
|
||||
>
|
||||
<div key={index} className={`flex gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
|
||||
{message.role === "assistant" && (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex-shrink-0 flex items-center justify-center shadow-md">
|
||||
<Bot className="text-white w-5 h-5" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 ${message.role === "user" ? "max-w-md ml-auto" : "max-w-2xl"}`}>
|
||||
<div className={`
|
||||
rounded-2xl p-6 shadow-md
|
||||
${message.role === "user"
|
||||
? "bg-gradient-to-br from-primary to-secondary text-white ml-auto"
|
||||
: "bg-card border border-border/50"
|
||||
|
||||
<div className={message.role === "user" ? "max-w-[85%] sm:max-w-md" : "max-w-full flex-1 sm:max-w-2xl"}>
|
||||
<div
|
||||
className={
|
||||
message.role === "user"
|
||||
? "rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-primary-foreground"
|
||||
: "rounded-2xl rounded-tl-sm border bg-card px-4 py-3"
|
||||
}
|
||||
`}>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
||||
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</p>
|
||||
|
||||
{message.variants && (
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="mt-4 space-y-3">
|
||||
{message.variants.map((variant, vIndex) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={vIndex}
|
||||
className="p-5 rounded-xl bg-muted/50 border border-border/50 cursor-pointer hover:bg-muted hover:shadow-md transition-all"
|
||||
onClick={() => copyVariant(variant.text)}
|
||||
className="group block w-full rounded-lg border bg-muted/40 p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
data-testid={`variant-${vIndex}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`
|
||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
||||
${vIndex === 0 ? "bg-gradient-to-r from-primary to-secondary text-white" : "bg-secondary/20 text-secondary"}
|
||||
`}>
|
||||
{variant.variant}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Badge variant={vIndex === 0 ? "default" : "muted"}>{variant.variant}</Badge>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{variant.characterCount} caractères
|
||||
<Copy className="h-3.5 w-3.5 opacity-60 group-hover:opacity-100" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-medium">{variant.characterCount} caractères</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">{variant.text}</p>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-foreground">{variant.text}</p>
|
||||
</button>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">Cliquez sur une proposition pour la copier.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-2 inline-block">
|
||||
{message.role === "user" ? "Maintenant" : "Il y a quelques secondes"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{message.role === "user" && (
|
||||
<div className="w-10 h-10 rounded-xl bg-secondary/20 flex-shrink-0 flex items-center justify-center">
|
||||
<User className="text-secondary w-5 h-5" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{generateMutation.isPending && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
L'IA rédige vos propositions…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-border/50 bg-card space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="border-t bg-card p-3 sm:p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
placeholder="Décrivez votre produit (nom, prix, caractéristiques)..."
|
||||
placeholder="Décrivez votre produit (nom, prix, caractéristiques)…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="flex-1 min-h-[80px] rounded-xl bg-muted/30 border-border/50 focus:border-primary/50"
|
||||
className="max-h-40 min-h-[48px] flex-1 resize-none"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -204,42 +205,20 @@ export default function AiChat() {
|
||||
}}
|
||||
data-testid="input-product-info"
|
||||
/>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={generateMutation.isPending || !input.trim()}
|
||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl px-6"
|
||||
variant="brand"
|
||||
className="h-12"
|
||||
data-testid="button-generate"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{generateMutation.isPending ? "Génération..." : "Générer"}
|
||||
{generateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">Générer</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={() => toast({ title: "Bientôt disponible", description: "Les suggestions de produits seront disponibles prochainement" })}
|
||||
data-testid="button-suggestions"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
Suggestions
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={() => toast({ title: "Bientôt disponible", description: "L'historique des générations sera disponible prochainement" })}
|
||||
data-testid="button-history"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
Historique
|
||||
</Button>
|
||||
<div className="ml-auto text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-success" />
|
||||
<span>Propulsé par OpenRouter</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 hidden text-xs text-muted-foreground sm:block">
|
||||
Entrée pour envoyer · Maj + Entrée pour aller à la ligne
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LogoMarkProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Pictogramme Social Flow : deux ondes qui « coulent » vers un point de diffusion. */
|
||||
export function LogoMark({ size = 36, className }: LogoMarkProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("shrink-0", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`sf-${id}`} x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#4F46E5" />
|
||||
<stop offset="0.6" stopColor="#7C3AED" />
|
||||
<stop offset="1" stopColor="#F97362" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="9" fill={`url(#sf-${id})`} />
|
||||
<path d="M7 12.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" strokeOpacity="0.55" strokeWidth="2.4" strokeLinecap="round" />
|
||||
<path d="M7 19.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" />
|
||||
<circle cx="25.2" cy="16" r="1.9" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface LogoProps extends LogoMarkProps {
|
||||
withText?: boolean;
|
||||
subtitle?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ size = 36, withText = true, subtitle = true, className }: LogoProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 min-w-0", className)}>
|
||||
<LogoMark size={size} />
|
||||
{withText && (
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="font-semibold tracking-tight text-foreground text-[17px]">
|
||||
Social<span className="text-primary">Flow</span>
|
||||
</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground truncate">Automatisation sociale</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Webcam from 'react-webcam';
|
||||
import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Video } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface CameraRecorderProps {
|
||||
onCapture: (file: File, options?: { stabilize?: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Quality configurations
|
||||
type VideoQuality = "4k" | "1080p" | "720p";
|
||||
|
||||
interface QualityConfig {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bitrate: number; // in bps
|
||||
}
|
||||
|
||||
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
|
||||
"4k": { label: "4K Vertical", width: 2160, height: 3840, bitrate: 40000000 },
|
||||
"1080p": { label: "Full HD (Reel)", width: 1080, height: 1920, bitrate: 12000000 }, // Align with 12Mbps max spec
|
||||
"720p": { label: "HD Vertical", width: 720, height: 1280, bitrate: 8000000 },
|
||||
};
|
||||
|
||||
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [timer, setTimer] = useState(0);
|
||||
|
||||
// Devices & Settings
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
|
||||
const [quality, setQuality] = useState<VideoQuality>('1080p');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [stabilize, setStabilize] = useState(true); // Stabilisation serveur activée par défaut
|
||||
|
||||
// Fetch devices
|
||||
const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => {
|
||||
const videoDevices = mediaDevices.filter(({ kind }) => kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
if (videoDevices.length > 0 && !selectedDeviceId) {
|
||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
||||
}
|
||||
}, [selectedDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.enumerateDevices().then(handleDevices).catch(err => {
|
||||
console.error("Error enumerating devices", err);
|
||||
setCameraError("Erreur liste périphériques: " + err.message);
|
||||
});
|
||||
}, [handleDevices]);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isRecording) {
|
||||
interval = setInterval(() => setTimer(t => t + 1), 1000);
|
||||
} else {
|
||||
setTimer(0);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isRecording]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Recording Logic
|
||||
const startRecording = useCallback(() => {
|
||||
if (!webcamRef.current || !webcamRef.current.stream) return;
|
||||
|
||||
chunksRef.current = [];
|
||||
const stream = webcamRef.current.stream;
|
||||
|
||||
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
|
||||
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||||
const currentConfig = QUALITY_CONFIGS[quality];
|
||||
|
||||
try {
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: currentConfig.bitrate // Bitrate dynamique selon qualité
|
||||
});
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
|
||||
setRecordedBlob(blob);
|
||||
setPreviewUrl(URL.createObjectURL(blob));
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
setIsRecording(true);
|
||||
} catch (e: any) {
|
||||
console.error("Recording error", e);
|
||||
toast({ title: "Erreur", description: "Impossible de lancer l'enregistrement: " + e.message, variant: "destructive" });
|
||||
}
|
||||
}, [webcamRef, toast]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (recordedBlob) {
|
||||
onCapture(
|
||||
new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }),
|
||||
{ stabilize }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMediaError = useCallback((error: string | DOMException) => {
|
||||
console.error("Webcam Error:", error);
|
||||
const msg = typeof error === 'string' ? error : error.message;
|
||||
setCameraError(msg);
|
||||
}, []);
|
||||
|
||||
const toggleCamera = () => {
|
||||
setFacingMode(prev => prev === "user" ? "environment" : "user");
|
||||
setSelectedDeviceId('');
|
||||
};
|
||||
|
||||
// Review Mode
|
||||
if (recordedBlob && previewUrl) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center p-4">
|
||||
<video src={previewUrl} controls className="w-full max-h-[80vh] rounded-lg mb-8" autoPlay playsInline />
|
||||
<div className="flex gap-4">
|
||||
<Button variant="secondary" size="lg" onClick={() => { setRecordedBlob(null); setPreviewUrl(null); }} className="rounded-full px-8 py-6 text-lg">
|
||||
<RotateCcw className="mr-2 h-5 w-5" /> Refaire
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleConfirm} className="rounded-full px-8 py-6 text-lg bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle className="mr-2 h-5 w-5" /> Valider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const config = QUALITY_CONFIGS[quality];
|
||||
const videoConstraints = {
|
||||
width: { ideal: config.width },
|
||||
height: { ideal: config.height },
|
||||
frameRate: { ideal: 30 }, // 30fps pour une capture fluide
|
||||
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||
facingMode: selectedDeviceId ? undefined : facingMode
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex justify-between items-center bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<Button variant="ghost" size="icon" onClick={onCancel} className="text-white bg-black/20 hover:bg-black/40">
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex gap-2">
|
||||
{isRecording ? (
|
||||
<div className="bg-red-600 px-3 py-1 rounded-full text-white font-mono text-sm flex items-center gap-2 animate-pulse">
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
{formatTime(timer)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={toggleCamera} className="bg-black/30 border-white/20 text-white backdrop-blur">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(!showSettings)} className="bg-black/30 border-white/20 text-white backdrop-blur">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Réglages</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Error Overlay */}
|
||||
{cameraError && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
|
||||
<h3 className="text-xl font-bold text-red-500 mb-2">Erreur Caméra</h3>
|
||||
<p className="text-white mb-6">{cameraError}</p>
|
||||
<p className="text-sm text-gray-400">Vérifiez vos permissions et que vous êtes bien en HTTPS (ou localhost).</p>
|
||||
<Button onClick={() => window.location.reload()} variant="outline" className="mt-4 text-black">Recharger</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Overlay */}
|
||||
{showSettings && !isRecording && (
|
||||
<div className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-[#1c1c1e] text-white p-6 rounded-2xl w-full max-w-sm border border-white/10 shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold">Paramètres</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowSettings(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70 block mb-2">Caméra</label>
|
||||
<Button variant="outline" className="w-full justify-start text-white border-white/20 hover:bg-white/10 mb-2" onClick={toggleCamera}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Basculer Avant/Arrière
|
||||
</Button>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{devices.map((device, idx) => (
|
||||
<button
|
||||
key={device.deviceId || idx}
|
||||
onClick={() => setSelectedDeviceId(device.deviceId)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm truncate border transition-colors ${selectedDeviceId === device.deviceId ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
|
||||
>
|
||||
{device.label || `Caméra ${idx + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70">Qualité</label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
{(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setQuality(q)}
|
||||
className={`px-2 py-2 rounded text-sm border transition-colors ${quality === q ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
|
||||
>
|
||||
{q.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70 flex items-center justify-between">
|
||||
<span>Stabilisation serveur</span>
|
||||
<button
|
||||
onClick={() => setStabilize(!stabilize)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${stabilize ? 'bg-green-500' : 'bg-gray-600'}`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${stabilize ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</label>
|
||||
<p className="text-xs text-white/50 mt-1">Corrige les tremblements après capture (traitement serveur)</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-6 bg-white text-black hover:bg-gray-200" onClick={() => setShowSettings(false)}>OK</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webcam Feed */}
|
||||
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden relative">
|
||||
<Webcam
|
||||
audio={true}
|
||||
ref={webcamRef}
|
||||
videoConstraints={videoConstraints}
|
||||
onUserMediaError={handleUserMediaError}
|
||||
className="absolute w-full h-full object-cover"
|
||||
muted // Local preview always muted to avoid feedback
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 pb-12 flex flex-col items-center justify-end bg-gradient-to-t from-black/80 to-transparent h-48 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="group relative flex items-center justify-center transition-all hover:scale-105 active:scale-95"
|
||||
>
|
||||
<div className={`w-20 h-20 rounded-full border-[6px] border-white flex items-center justify-center p-1 ${isRecording ? 'opacity-100' : ''}`}>
|
||||
<div className={`transition-all duration-300 ${isRecording ? 'w-8 h-8 rounded-md bg-red-500' : 'w-full h-full rounded-full bg-red-500'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CameraRecorder;
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** État vide homogène : icône, message et action suggérée. */
|
||||
export function EmptyState({ icon: Icon, title, description, action, className, compact = false }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl border border-dashed bg-muted/30 text-center",
|
||||
compact ? "px-4 py-8" : "px-6 py-14",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-4 flex items-center justify-center rounded-full bg-primary/10 text-primary", compact ? "h-10 w-10" : "h-14 w-14")}>
|
||||
<Icon className={compact ? "h-5 w-5" : "h-6 w-6"} />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
{description && <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>}
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { LogoMark } from "@/components/brand/logo";
|
||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||
import { titleForPath } from "@/components/layout/nav-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Cadre de l'application connectée : sidebar (desktop), en-tête + barre basse (mobile). */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b bg-card/90 px-4 backdrop-blur-lg lg:hidden">
|
||||
<Link href="/" aria-label="Accueil">
|
||||
<LogoMark size={30} />
|
||||
</Link>
|
||||
<p className="truncate font-semibold">{titleForPath(location)}</p>
|
||||
</header>
|
||||
<main className="flex-1 pb-24 lg:pb-0">{children}</main>
|
||||
</div>
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WIDTHS = {
|
||||
narrow: "max-w-3xl",
|
||||
default: "max-w-6xl",
|
||||
wide: "max-w-[1400px]",
|
||||
} as const;
|
||||
|
||||
interface PageProps {
|
||||
children: ReactNode;
|
||||
width?: keyof typeof WIDTHS;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Conteneur de contenu standard : largeur max, marges et animation d'entrée. */
|
||||
export function Page({ children, width = "default", className }: PageProps) {
|
||||
return (
|
||||
<div className={cn("fade-in mx-auto w-full px-4 py-5 sm:px-6 sm:py-8 lg:px-8", WIDTHS[width], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronRight, Home, Images, LogOut, Menu, Plus } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ThemeToggle } from "@/components/theme-provider";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { CREATE_ITEMS, isActivePath, visibleGroups } from "@/components/layout/nav-config";
|
||||
import { useLogout, useSession } from "@/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/", label: "Accueil", icon: Home, testId: "tab-home" },
|
||||
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "tab-calendar" },
|
||||
{ href: "/media", label: "Médias", icon: Images, testId: "tab-media" },
|
||||
];
|
||||
|
||||
/** Barre de navigation inférieure (mobile et tablette, < lg). */
|
||||
export function MobileNav() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { session, isAdmin } = useSession();
|
||||
const logout = useLogout();
|
||||
|
||||
const go = (href: string) => {
|
||||
setCreateOpen(false);
|
||||
setMenuOpen(false);
|
||||
setLocation(href);
|
||||
};
|
||||
|
||||
const tab = (t: (typeof TABS)[number]) => {
|
||||
const active = isActivePath(location, t.href);
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
|
||||
active ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-testid={t.testId}
|
||||
>
|
||||
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.4 : 2} />
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
// Les entrées déjà accessibles par les onglets ou « Créer » ne sont pas répétées dans le menu.
|
||||
const menuGroups = visibleGroups(isAdmin)
|
||||
.filter((g) => g.label !== "Créer")
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => !["/", "/calendar", "/media"].includes(i.href)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/95 backdrop-blur-lg pb-safe lg:hidden"
|
||||
aria-label="Navigation mobile"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-xl items-stretch px-2">
|
||||
{tab(TABS[0])}
|
||||
{tab(TABS[1])}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex h-12 w-12 -translate-y-3 items-center justify-center rounded-2xl gradient-brand text-white shadow-lg shadow-primary/30 ring-4 ring-background"
|
||||
aria-label="Créer"
|
||||
data-testid="tab-create"
|
||||
>
|
||||
<Plus className="h-6 w-6" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
{tab(TABS[2])}
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
|
||||
menuOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
data-testid="tab-menu"
|
||||
>
|
||||
<Menu className="h-[22px] w-[22px]" />
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent side="bottom" className="rounded-t-2xl pb-8">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle>Que voulez-vous créer ?</SheetTitle>
|
||||
<SheetDescription>Choisissez le type de contenu à préparer.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{CREATE_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => go(item.href)}
|
||||
className="flex flex-col items-start gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
data-testid={`create-${item.href.replace(/\W+/g, "-")}`}
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{item.label}</span>
|
||||
{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-2xl pb-8">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription className="sr-only">Toutes les sections de Social Flow</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{session && (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl bg-muted/60 p-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||
{session.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menuGroups.map((group) => (
|
||||
<div key={group.label ?? "root"} className="mt-5">
|
||||
{group.label && (
|
||||
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group.label}</p>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
{group.items.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => go(item.href)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-accent",
|
||||
i > 0 && "border-t",
|
||||
isActivePath(location, item.href) && "text-primary",
|
||||
)}
|
||||
data-testid={`menu-${item.testId}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="flex-1 font-medium">{item.label}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Apparence</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium text-destructive hover:bg-destructive/5"
|
||||
data-testid="button-logout-mobile"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
||||
</button>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Database,
|
||||
Film,
|
||||
Home,
|
||||
Images,
|
||||
Link2,
|
||||
Music,
|
||||
PenSquare,
|
||||
Settings,
|
||||
UserCog,
|
||||
Wand2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Courte description affichée dans les menus mobiles. */
|
||||
description?: string;
|
||||
adminOnly?: boolean;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string | null;
|
||||
items: NavItem[];
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Source unique de la navigation (sidebar desktop, barre mobile, menus). */
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: null,
|
||||
items: [{ href: "/", label: "Tableau de bord", icon: Home, testId: "link-tableau-de-bord" }],
|
||||
},
|
||||
{
|
||||
label: "Créer",
|
||||
items: [
|
||||
{ href: "/new", label: "Publication", icon: PenSquare, description: "Photo ou vidéo avec texte", testId: "link-nouvelle-publication" },
|
||||
{ href: "/reel", label: "Reel", icon: Film, description: "Montage guidé avec musique et voix", testId: "link-nouveau-reel" },
|
||||
{ href: "/reel/schedule", label: "Programmer un Reel", icon: CalendarClock, description: "Vidéo déjà montée", testId: "link-programmer-un-reel" },
|
||||
{ href: "/image-editor", label: "Éditeur d'images", icon: Wand2, description: "Bandeaux, prix et logo", testId: "link-éditeur-d'images" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Planifier",
|
||||
items: [
|
||||
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "link-calendrier" },
|
||||
{ href: "/history", label: "Historique", icon: Clock, testId: "link-historique" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Bibliothèque",
|
||||
items: [
|
||||
{ href: "/media", label: "Médiathèque", icon: Images, testId: "link-médiathèque" },
|
||||
{ href: "/audio-admin", label: "Musiques", icon: Music, adminOnly: true, testId: "link-audio-admin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Analyser",
|
||||
items: [
|
||||
{ href: "/analytics", label: "Statistiques", icon: BarChart3, testId: "link-analytics" },
|
||||
{ href: "/ai", label: "Assistant IA", icon: Bot, adminOnly: true, testId: "link-assistant-ia" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Administration",
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ href: "/pages", label: "Comptes connectés", icon: Link2, testId: "link-pages-gérées" },
|
||||
{ href: "/users", label: "Utilisateurs", icon: UserCog, testId: "link-users" },
|
||||
{ href: "/settings", label: "Paramètres", icon: Settings, testId: "link-settings" },
|
||||
{ href: "/sql", label: "Base de données", icon: Database, testId: "link-sql" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CREATE_ITEMS = NAV_GROUPS.find((g) => g.label === "Créer")!.items;
|
||||
|
||||
export function visibleGroups(isAdmin: boolean): NavGroup[] {
|
||||
return NAV_GROUPS.filter((g) => isAdmin || !g.adminOnly)
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => isAdmin || !i.adminOnly) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
export function isActivePath(location: string, href: string): boolean {
|
||||
if (href === "/") return location === "/";
|
||||
// /reel ne doit pas rester actif sur /reel/schedule
|
||||
if (href === "/reel") return location === "/reel";
|
||||
return location === href || location.startsWith(href + "/");
|
||||
}
|
||||
|
||||
/** Titre de la page courante (header mobile). */
|
||||
export function titleForPath(location: string): string {
|
||||
for (const g of NAV_GROUPS) {
|
||||
for (const i of g.items) {
|
||||
if (isActivePath(location, i.href)) return i.label;
|
||||
}
|
||||
}
|
||||
return "Social Flow";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
/** Boutons d'action affichés à droite (empilés sous le titre sur mobile). */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** En-tête standard de toutes les pages : icône, titre, description et actions. */
|
||||
export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between", className)}>
|
||||
<div className="flex min-w-0 items-start gap-3 sm:gap-4">
|
||||
{Icon && (
|
||||
<div className="hidden h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[28px] sm:leading-9">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground sm:text-[15px]">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Titre de section à l'intérieur d'une page. */
|
||||
export function SectionHeader({ title, description, actions, className }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,83 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Users } from "lucide-react";
|
||||
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowRight, Link2, Users } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
|
||||
|
||||
interface ManagedPage {
|
||||
id: string;
|
||||
pageName: string;
|
||||
platform: string;
|
||||
isActive: string;
|
||||
followersCount?: number | null;
|
||||
}
|
||||
|
||||
export default function ManagedPages() {
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
const { data: pages, isLoading } = useQuery<ManagedPage[]>({
|
||||
queryKey: ["/api/pages"],
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-8 shadow-lg">
|
||||
<div className="skeleton h-8 w-48 mb-6 rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-40 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||
<Users className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Pages gérées</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(pages as any[])?.length || 0} page(s) connectée(s)
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Comptes connectés</CardTitle>
|
||||
<CardDescription>{pages?.length ?? 0} compte(s) prêt(s) à publier</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages && (pages as any[]).length > 0 ? (
|
||||
(pages as any[]).map((page: any) => (
|
||||
<div
|
||||
key={page.id}
|
||||
className="border border-border/50 rounded-2xl p-6 hover:shadow-xl hover:border-primary/30 transition-all cursor-pointer bg-card"
|
||||
data-testid={`page-card-${page.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className={`
|
||||
w-14 h-14 rounded-xl flex items-center justify-center shadow-md
|
||||
${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
|
||||
`}>
|
||||
{page.platform === "facebook" ? (
|
||||
<SiFacebook className="text-[#1877F2] text-2xl" />
|
||||
) : page.platform === "tiktok" ? (
|
||||
<SiTiktok className="text-foreground text-2xl" />
|
||||
) : (
|
||||
<SiInstagram className="text-[#E4405F] text-2xl" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`
|
||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
||||
${page.isActive === "true" ? "bg-success/20 text-success" : "bg-muted text-muted-foreground"}
|
||||
`}>
|
||||
{page.isActive === "true" ? "Actif" : "Inactif"}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4 font-medium">
|
||||
{page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-medium">
|
||||
{page.followersCount?.toLocaleString() || 0} abonnés
|
||||
</span>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary">
|
||||
<Link href="/pages">
|
||||
Gérer <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-[72px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : pages && pages.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{pages.map((page) => (
|
||||
<div key={page.id} className="flex items-center gap-3 rounded-lg border p-3" data-testid={`page-card-${page.id}`}>
|
||||
<PlatformIcon platform={page.platform} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{page.pageName}</p>
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{platformLabel(page.platform)}
|
||||
{page.followersCount ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Users className="h-3 w-3" />
|
||||
{page.followersCount.toLocaleString("fr-FR")}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={page.isActive === "true" ? "success" : "muted"}>
|
||||
{page.isActive === "true" ? "Actif" : "Inactif"}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mx-auto mb-6">
|
||||
<Users className="w-10 h-10 text-muted-foreground opacity-50" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">Aucune page connectée</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={Link2}
|
||||
title="Aucun compte connecté"
|
||||
description="Connectez une page Facebook, Instagram ou TikTok pour commencer à publier."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/pages">Connecter un compte</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -23,12 +23,7 @@ function getStageLabel(post: OngoingReel): string {
|
||||
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
|
||||
}
|
||||
|
||||
interface OngoingReelsProps {
|
||||
/** Compact layout for mobile */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
export default function OngoingReels() {
|
||||
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||
queryKey: ["/api/reels/ongoing"],
|
||||
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
||||
@@ -65,13 +60,9 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : ""
|
||||
}`}
|
||||
data-testid="card-ongoing-reels"
|
||||
>
|
||||
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||
<Card data-testid="card-ongoing-reels">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
@@ -80,8 +71,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
Reels en cours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||
<div className={`space-y-${compact ? "3" : "4"}`}>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{ongoingPosts.map((post) => {
|
||||
const progress = post.generationProgress ?? 0;
|
||||
const isFailed = post.generationStatus === "failed";
|
||||
@@ -89,17 +80,17 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`flex items-start gap-3 p-${compact ? "3" : "4"} rounded-lg border border-border/50 bg-muted/30`}
|
||||
className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3"
|
||||
data-testid={`ongoing-reel-${post.id}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4 text-white" />
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${compact ? "text-sm" : "text-sm"}`}>
|
||||
<p className="font-medium truncate text-sm">
|
||||
{post.content || "Reel sans texte"}
|
||||
</p>
|
||||
|
||||
@@ -130,7 +121,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
)}
|
||||
|
||||
{progress >= 100 && !isFailed && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-green-500">
|
||||
<div className="flex items-center gap-1.5 mt-2 text-success">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Terminé</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Platform = "facebook" | "instagram" | "tiktok" | string;
|
||||
|
||||
const META: Record<string, { label: string; icon: typeof SiFacebook; text: string; bg: string }> = {
|
||||
facebook: { label: "Facebook", icon: SiFacebook, text: "text-facebook", bg: "bg-facebook/10" },
|
||||
instagram: { label: "Instagram", icon: SiInstagram, text: "text-instagram", bg: "bg-instagram/10" },
|
||||
tiktok: { label: "TikTok", icon: SiTiktok, text: "text-tiktok", bg: "bg-tiktok/10" },
|
||||
};
|
||||
|
||||
export function platformLabel(platform: Platform): string {
|
||||
return META[platform]?.label ?? platform;
|
||||
}
|
||||
|
||||
/** Icône de plateforme sur pastille teintée (couleurs officielles via tokens). */
|
||||
export function PlatformIcon({
|
||||
platform,
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
platform: Platform;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = META[platform] ?? META.facebook;
|
||||
const Icon = meta.icon;
|
||||
const box = { sm: "h-7 w-7 rounded-md", md: "h-10 w-10 rounded-lg", lg: "h-12 w-12 rounded-xl" }[size];
|
||||
const icon = { sm: "h-3.5 w-3.5", md: "h-5 w-5", lg: "h-6 w-6" }[size];
|
||||
return (
|
||||
<span className={cn("inline-flex shrink-0 items-center justify-center", box, meta.bg, meta.text, className)}>
|
||||
<Icon className={icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Image, Video, CalendarPlus, Clock, Plug, Zap, PlusCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { data: recentActivity } = useQuery({
|
||||
queryKey: ["/api/posts"],
|
||||
select: (data) => (data as any[])?.slice(0, 4) || [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||
<PlusCircle className="text-white w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Nouvelle publication</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl" data-testid="button-new-media-post">
|
||||
<Image className="w-4 h-4 mr-2" />
|
||||
Publication avec média
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-new-story">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Story vidéo
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-schedule-series">
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Planifier série
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
|
||||
<Clock className="text-warning w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Activité récente</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivity && recentActivity.length > 0 ? (
|
||||
recentActivity.map((activity: any, index: number) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-4 pb-4 border-b border-border/50 last:border-0 last:pb-0"
|
||||
data-testid={`activity-${index}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-success mt-2 shadow-sm"></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{activity.status === "scheduled" ? "Publication planifiée" : "Brouillon créé"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-medium">
|
||||
{new Date(activity.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-12 h-12 text-muted-foreground opacity-30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Aucune activité récente</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center">
|
||||
<Zap className="text-success w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">État des services</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between" data-testid="status-openrouter">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">OpenRouter API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-facebook">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">Facebook API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-instagram">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">Instagram API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-database">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">PostgreSQL</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import { ArrowRight, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PlatformIcon } from "@/components/platform-icon";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||
@@ -148,115 +151,106 @@ export default function RecentPublications() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg" data-testid="card-recent-publications">
|
||||
<CardHeader>
|
||||
<CardTitle>Historique des publications</CardTitle>
|
||||
<CardDescription>
|
||||
Vos 10 dernières publications sur les réseaux sociaux
|
||||
</CardDescription>
|
||||
<Card data-testid="card-recent-publications">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Dernières publications</CardTitle>
|
||||
<CardDescription>Les 10 publications les plus récentes</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary">
|
||||
<Link href="/history">
|
||||
Tout voir <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-muted rounded-lg animate-pulse" />
|
||||
<div key={i} className="skeleton h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : recentPublished.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Aucune publication pour le moment</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
compact
|
||||
icon={Clock}
|
||||
title="Aucune publication pour le moment"
|
||||
description="Vos publications apparaîtront ici dès qu'elles seront envoyées."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/new">Créer une publication</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ul className="divide-y">
|
||||
{recentPublished.map((scheduledPost) => (
|
||||
<div
|
||||
<li
|
||||
key={scheduledPost.id}
|
||||
className="flex items-start gap-4 p-4 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors"
|
||||
className="group flex items-center gap-3 py-3 first:pt-0 last:pb-0"
|
||||
data-testid={`publication-${scheduledPost.id}`}
|
||||
>
|
||||
{/* Platform icon */}
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
|
||||
? 'bg-blue-500'
|
||||
: scheduledPost.page?.platform === 'tiktok'
|
||||
? 'bg-black'
|
||||
: 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
}`}>
|
||||
{scheduledPost.page?.platform === 'facebook' ? (
|
||||
<Facebook className="w-5 h-5 text-white" />
|
||||
) : scheduledPost.page?.platform === 'tiktok' ? (
|
||||
<SiTiktok className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Instagram className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm truncate" data-testid={`text-page-name-${scheduledPost.id}`}>
|
||||
{scheduledPost.page?.pageName || 'Page inconnue'}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{scheduledPost.post?.content ? (
|
||||
<span className="line-clamp-2">{scheduledPost.post.content}</span>
|
||||
) : (
|
||||
<span className="italic">Aucun texte</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePreviewPost(scheduledPost)}
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid={`button-preview-post-${scheduledPost.id}`}
|
||||
title="Prévisualiser"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(scheduledPost.postId)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
data-testid={`button-delete-post-${scheduledPost.id}`}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
{scheduledPost.error ? (
|
||||
<XCircle className="w-4 h-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === 'feed' ? 'Feed' :
|
||||
scheduledPost.postType === 'story' ? 'Story' :
|
||||
scheduledPost.postType === 'reel' ? 'Reel' : 'Feed & Story'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||
</span>
|
||||
{scheduledPost.error && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-destructive">Erreur</span>
|
||||
</>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium" data-testid={`text-page-name-${scheduledPost.id}`}>
|
||||
{scheduledPost.page?.pageName || "Page inconnue"}
|
||||
</p>
|
||||
{scheduledPost.error ? (
|
||||
<Badge variant="danger" className="gap-1">
|
||||
<XCircle className="h-3 w-3" /> Échec
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> Publié
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === "feed"
|
||||
? "Feed"
|
||||
: scheduledPost.postType === "story"
|
||||
? "Story"
|
||||
: scheduledPost.postType === "reel"
|
||||
? "Reel"
|
||||
: "Feed & Story"}
|
||||
<span>·</span>
|
||||
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePreviewPost(scheduledPost)}
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
data-testid={`button-preview-post-${scheduledPost.id}`}
|
||||
title="Prévisualiser"
|
||||
aria-label="Prévisualiser"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(scheduledPost.postId)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
data-testid={`button-delete-post-${scheduledPost.id}`}
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -274,7 +268,7 @@ export default function RecentPublications() {
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Supprimer cette publication ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action est irréversible. Cela supprimera définitivement cette publication de votre historique et des statistiques associées.
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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} />;
|
||||
};
|
||||
}
|
||||
+151
-278
@@ -1,305 +1,178 @@
|
||||
import { Home, PlusCircle, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronsLeft, ChevronsRight, LogOut, Plus } from "lucide-react";
|
||||
import { Logo, LogoMark } from "@/components/brand/logo";
|
||||
import { ThemeToggle } from "@/components/theme-provider";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isActivePath, visibleGroups, type NavItem } from "@/components/layout/nav-config";
|
||||
import { useLogout, useSession } from "@/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
onLinkClick?: () => void;
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("sidebar-collapsed") === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
function NavLink({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||
const Icon = item.icon;
|
||||
const link = (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
collapsed && "justify-center px-0",
|
||||
)}
|
||||
data-testid={item.testId}
|
||||
>
|
||||
{active && <span className="absolute -left-3 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
|
||||
<Icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!collapsed) return link;
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Barre latérale desktop (≥ lg). Sur mobile, la navigation passe par MobileNav. */
|
||||
export default function Sidebar() {
|
||||
const [location] = useLocation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed');
|
||||
return saved === 'true';
|
||||
});
|
||||
const [adminExpanded, setAdminExpanded] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-admin-expanded');
|
||||
return saved !== 'false'; // default: expanded
|
||||
});
|
||||
const { session, isAdmin } = useSession();
|
||||
const logout = useLogout();
|
||||
const [collapsed, setCollapsed] = useState(readCollapsed);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-collapsed', String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-admin-expanded', String(adminExpanded));
|
||||
}, [adminExpanded]);
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
queryKey: ["/api/auth/session"],
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const isAdmin = session?.role === "admin";
|
||||
|
||||
const handleLinkClick = (href: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Always close sidebar on mobile first
|
||||
if (onLinkClick) {
|
||||
onLinkClick();
|
||||
try {
|
||||
localStorage.setItem("sidebar-collapsed", String(collapsed));
|
||||
} catch {
|
||||
/* stockage indisponible : on ignore */
|
||||
}
|
||||
// Navigate after a small delay to ensure state update completes
|
||||
setTimeout(() => {
|
||||
setLocation(href);
|
||||
}, 10);
|
||||
};
|
||||
}, [collapsed]);
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: [AUTH_MUTATION, "logout"],
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/auth/logout", {});
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
setLocation("/login");
|
||||
toast({
|
||||
title: "Déconnecté",
|
||||
description: "Vous avez été déconnecté avec succès",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: null },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: null },
|
||||
];
|
||||
|
||||
const statsItems = [
|
||||
{ icon: Clock, label: "Historique", href: "/history" },
|
||||
];
|
||||
const groups = visibleGroups(isAdmin);
|
||||
const initials = session?.username.substring(0, 2).toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<aside className={`bg-sidebar border-r border-sidebar-border flex flex-col h-screen transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-72'}`}>
|
||||
{/* Logo & Brand */}
|
||||
<div className="p-6 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 gradient-primary rounded-xl flex items-center justify-center shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold gradient-text">Social Flow</h1>
|
||||
<p className="text-xs text-muted-foreground">Automatisation sociale</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 lg:flex",
|
||||
collapsed ? "w-[76px]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border", collapsed ? "justify-center" : "px-5")}>
|
||||
<Link href="/" aria-label="Accueil Social Flow">
|
||||
{collapsed ? <LogoMark size={34} /> : <Logo size={34} />}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
<div className={cn("pt-4", collapsed ? "px-3" : "px-4")}>
|
||||
<Button asChild variant="brand" className={cn("w-full", collapsed && "px-0")} data-testid="button-sidebar-create">
|
||||
<Link href="/new" aria-label="Créer une publication">
|
||||
<Plus className="h-4 w-4" />
|
||||
{!collapsed && "Créer"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl font-medium transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold gradient-primary text-white shadow-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="mt-8 pt-6 border-t border-sidebar-border">
|
||||
{!isCollapsed && (
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-3 px-4 uppercase tracking-wider">Statistiques</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{statsItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
<nav className={cn("flex-1 overflow-y-auto py-4 scrollbar-none", collapsed ? "px-3" : "px-4")} aria-label="Navigation principale">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.label ?? gi} className={cn(gi > 0 && "mt-5")}>
|
||||
{group.label &&
|
||||
(collapsed ? (
|
||||
<div className="mx-auto mb-2 h-px w-6 bg-sidebar-border" />
|
||||
) : (
|
||||
<p className="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
|
||||
{group.label}
|
||||
</p>
|
||||
))}
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavLink item={item} active={isActivePath(location, item.href)} collapsed={collapsed} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Admin & User Section */}
|
||||
<div className="p-4 border-t border-sidebar-border space-y-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
{/* Admin section toggle */}
|
||||
<button
|
||||
onClick={() => !isCollapsed && setAdminExpanded(v => !v)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-xl transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent relative group ${isCollapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<Shield className="w-4 h-4 shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider flex-1 text-left">Administration</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${adminExpanded ? '' : '-rotate-90'}`} />
|
||||
</>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
Administration
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Admin links — collapsible */}
|
||||
{(adminExpanded || isCollapsed) && (
|
||||
<div className={`space-y-0.5 ${!isCollapsed ? 'pl-2' : ''}`}>
|
||||
{[
|
||||
{ href: "/pages", icon: Users, label: "Pages gérées", testId: "link-pages-gérées" },
|
||||
{ href: "/ai", icon: Bot, label: "Assistant IA", testId: "link-assistant-ia" },
|
||||
{ href: "/users", icon: UserCog, label: "Utilisateurs", testId: "link-users" },
|
||||
{ href: "/sql", icon: Database,label: "SQL", testId: "link-sql" },
|
||||
{ href: "/audio-admin", icon: Music, label: "Bibliothèque Audio", testId: "link-audio-admin" },
|
||||
{ href: "/settings", icon: Settings,label: "Paramètres", testId: "link-settings" },
|
||||
].map(({ href, icon: Icon, label, testId }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={(e) => handleLinkClick(href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === href
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={testId}
|
||||
>
|
||||
{location === href && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{!isCollapsed && <span className="text-sm">{label}</span>}
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-2 relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-xl ${isCollapsed ? 'justify-center px-0' : 'justify-start'}`}
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending}
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className={`w-5 h-5 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && (logoutMutation.isPending ? "Déconnexion..." : "Déconnexion")}
|
||||
</Button>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 top-0">
|
||||
Déconnexion
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex flex-col items-center gap-2")}>
|
||||
{session && (
|
||||
<div className={`mt-4 bg-gradient-to-br from-primary/5 to-secondary/5 border border-primary/10 rounded-xl ${isCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<div className={`flex items-center gap-3 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<Avatar className="w-10 h-10 border-2 border-primary/20">
|
||||
<AvatarFallback className="gradient-primary text-white font-semibold">
|
||||
{session.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{session.role}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-sidebar-accent",
|
||||
collapsed && "justify-center",
|
||||
)}
|
||||
data-testid="button-user-menu"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={collapsed ? "right" : "top"} align="start" className="w-60">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">Apparence</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Toggle Button (Desktop only) */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="mt-2 w-full hidden lg:flex items-center justify-center p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 border-2 border-border hover:border-primary/50 rounded-xl transition-all"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"mt-1 flex w-full items-center justify-center gap-2 rounded-lg p-2 text-xs text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
|
||||
)}
|
||||
aria-label={collapsed ? "Déplier le menu" : "Replier le menu"}
|
||||
data-testid="button-toggle-sidebar"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
||||
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <><ChevronsLeft className="h-4 w-4" /> Replier</>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarCheck, Users, Bot, Images, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { CalendarCheck, Link2, Sparkles, Images, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatsResponse {
|
||||
scheduledPosts: number;
|
||||
@@ -20,46 +22,49 @@ export default function StatsCards() {
|
||||
const statCards = [
|
||||
{
|
||||
icon: CalendarCheck,
|
||||
iconColor: "from-blue-500 to-blue-600",
|
||||
title: stats?.scheduledPosts ?? 0,
|
||||
subtitle: "Publications planifiées",
|
||||
tone: "bg-primary/10 text-primary",
|
||||
value: stats?.scheduledPosts ?? 0,
|
||||
label: "Publications planifiées",
|
||||
change: stats?.scheduledPostsChange,
|
||||
trending: stats?.scheduledPostsTrending,
|
||||
changeLabel: "vs mois dernier",
|
||||
href: "/calendar",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
iconColor: "from-purple-500 to-purple-600",
|
||||
title: stats?.connectedPages ?? 0,
|
||||
subtitle: "Pages connectées",
|
||||
info: "Facebook • Instagram",
|
||||
icon: Link2,
|
||||
tone: "bg-info/10 text-info",
|
||||
value: stats?.connectedPages ?? 0,
|
||||
label: "Comptes connectés",
|
||||
info: "Facebook · Instagram · TikTok",
|
||||
href: "/pages",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
iconColor: "from-green-500 to-green-600",
|
||||
title: stats?.aiTextsGenerated ?? 0,
|
||||
subtitle: "Textes générés par IA",
|
||||
icon: Sparkles,
|
||||
tone: "bg-brand-accent/15 text-brand-accent",
|
||||
value: stats?.aiTextsGenerated ?? 0,
|
||||
label: "Textes générés par l'IA",
|
||||
change: stats?.aiTextsChange,
|
||||
trending: stats?.aiTextsTrending,
|
||||
changeLabel: "vs hier",
|
||||
},
|
||||
{
|
||||
icon: Images,
|
||||
iconColor: "from-cyan-500 to-cyan-600",
|
||||
title: stats?.mediaStored ?? 0,
|
||||
subtitle: "Médias stockés",
|
||||
info: "Images • Vidéos",
|
||||
tone: "bg-success/10 text-success",
|
||||
value: stats?.mediaStored ?? 0,
|
||||
label: "Médias stockés",
|
||||
info: "Images · Vidéos",
|
||||
href: "/media",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-card rounded-2xl border border-border p-6 shadow-sm">
|
||||
<div className="skeleton h-12 w-12 rounded-xl mb-4" />
|
||||
<div className="skeleton h-10 w-20 mb-2" />
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div key={i} className="rounded-xl border bg-card p-4 shadow-soft sm:p-5">
|
||||
<div className="skeleton mb-4 h-10 w-10 rounded-lg" />
|
||||
<div className="skeleton mb-2 h-7 w-16 rounded" />
|
||||
<div className="skeleton h-4 w-28 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -67,45 +72,45 @@ export default function StatsCards() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
{statCards.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const TrendIcon = stat.trending === "up" ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group bg-card rounded-2xl border border-border p-6 hover:border-primary/30 transition-all cursor-pointer card-hover shadow-sm"
|
||||
data-testid={`stat-card-${index}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${stat.iconColor} flex items-center justify-center shadow-lg`}>
|
||||
<Icon className="text-white w-7 h-7" />
|
||||
</div>
|
||||
const body = (
|
||||
<>
|
||||
<div className={cn("mb-4 flex h-10 w-10 items-center justify-center rounded-lg", stat.tone)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-4xl font-bold text-foreground" data-testid={`stat-value-${index}`}>
|
||||
{stat.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{stat.change && (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}`}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
<span className="text-xs font-semibold">{stat.change}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{stat.changeLabel}</span>
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground sm:text-3xl" data-testid={`stat-value-${index}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{stat.label}</p>
|
||||
{stat.change ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium",
|
||||
stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
{stat.change}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{stat.changeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stat.info && (
|
||||
<div className="mt-4">
|
||||
<span className="text-xs text-muted-foreground font-medium">{stat.info}</span>
|
||||
</div>
|
||||
)}
|
||||
) : stat.info ? (
|
||||
<p className="mt-3 hidden text-xs text-muted-foreground sm:block">{stat.info}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const cls = "block rounded-xl border bg-card p-4 shadow-soft sm:p-5";
|
||||
return stat.href ? (
|
||||
<Link key={index} href={stat.href} className={cn(cls, "card-hover")} data-testid={`stat-card-${index}`}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={index} className={cls} data-testid={`stat-card-${index}`}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem storageKey="theme" disableTransitionOnChange>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "light", label: "Clair", icon: Sun },
|
||||
{ value: "dark", label: "Sombre", icon: Moon },
|
||||
{ value: "system", label: "Système", icon: Monitor },
|
||||
] as const;
|
||||
|
||||
/** Sélecteur segmenté clair / sombre / système. */
|
||||
export function ThemeToggle({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const current = theme ?? "system";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Thème de l'interface"
|
||||
className={cn("inline-flex w-full items-center gap-1 rounded-lg bg-muted p-1", className)}
|
||||
>
|
||||
{OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={current === value}
|
||||
title={label}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors",
|
||||
current === value ? "bg-card text-foreground shadow-sm" : "hover:text-foreground",
|
||||
)}
|
||||
data-testid={`theme-${value}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{!compact && <span>{label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Menu, Search, ChevronLeft } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useLocation, Link } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuClick?: () => void;
|
||||
hideMenuButton?: boolean;
|
||||
}
|
||||
|
||||
export default function TopBar({ onMenuClick, hideMenuButton = false }: TopBarProps) {
|
||||
const [location] = useLocation();
|
||||
const showBackButton = location !== "/";
|
||||
|
||||
return (
|
||||
<header className="bg-card/50 backdrop-blur-xl border-b border-border/50 sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4 sm:py-5">
|
||||
<div className="flex items-center gap-3 sm:gap-6 flex-1">
|
||||
<div className="lg:hidden flex items-center gap-2">
|
||||
{showBackButton ? (
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="icon" className="text-foreground hover:text-primary transition-all">
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
!hideMenuButton && (
|
||||
<button
|
||||
className="text-foreground hover:text-primary transition-all hover:scale-110 active:scale-95 p-2 hover:bg-primary/10 rounded-xl"
|
||||
onClick={onMenuClick}
|
||||
data-testid="button-menu"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 max-w-md hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher des publications, pages, médias..."
|
||||
className="pl-10 bg-muted/30 border-muted focus:border-primary/50 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -15,6 +15,11 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-success/15 text-success",
|
||||
warning: "border-transparent bg-warning/15 text-warning-foreground dark:text-warning",
|
||||
info: "border-transparent bg-info/15 text-info",
|
||||
danger: "border-transparent bg-destructive/15 text-destructive",
|
||||
muted: "border-transparent bg-muted text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,25 +5,27 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
brand:
|
||||
"gradient-brand text-white shadow-md shadow-primary/20 hover:opacity-95 hover:shadow-lg hover:shadow-primary/25",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-11 rounded-md px-3",
|
||||
lg: "h-12 rounded-md px-8",
|
||||
icon: "h-11 w-11",
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-lg px-6 text-[15px]",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border bg-card text-card-foreground shadow-soft",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-5 sm:p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-base font-semibold leading-tight tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-5 pt-0 sm:p-6 sm:pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
className={cn("flex items-center p-5 pt-0 sm:p-6 sm:pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-card px-3 py-2 text-base shadow-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-ring/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-card shadow-sm px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in new issue
Block a user