mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
Refonte UI : paramètres en onglets, nouvelle publication en 4 étapes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
This commit is contained in:
16 files changed
+947
-594
No files matched your search
@@ -130,7 +130,7 @@ export function AnalyticsDashboard() {
|
|||||||
onClick={() => refreshMutation.mutate()}
|
onClick={() => refreshMutation.mutate()}
|
||||||
disabled={refreshMutation.isPending || !selectedPageId}
|
disabled={refreshMutation.isPending || !selectedPageId}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
Actualiser les données
|
Actualiser les données
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export default function CalendarView() {
|
|||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
data-testid="button-prev-month"
|
data-testid="button-prev-month"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Précédent
|
Précédent
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-semibold text-foreground min-w-[140px] text-center px-4 py-2 bg-muted/30 rounded-xl">
|
<span className="text-sm font-semibold text-foreground min-w-[140px] text-center px-4 py-2 bg-muted/30 rounded-xl">
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
|||||||
)}
|
)}
|
||||||
data-testid="button-date-picker"
|
data-testid="button-date-picker"
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="h-4 w-4" />
|
||||||
{value ? format(value, "PPP", { locale: fr }) : <span>{placeholder}</span>}
|
{value ? format(value, "PPP", { locale: fr }) : <span>{placeholder}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -131,7 +131,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
|||||||
className="w-full justify-start text-left font-normal"
|
className="w-full justify-start text-left font-normal"
|
||||||
data-testid="button-time-picker"
|
data-testid="button-time-picker"
|
||||||
>
|
>
|
||||||
<Clock className="mr-2 h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{String(selectedHour).padStart(2, '0')}:{String(selectedMinute).padStart(2, '0')}
|
{String(selectedHour).padStart(2, '0')}:{String(selectedMinute).padStart(2, '0')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export default function MediaUpload() {
|
|||||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl"
|
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl"
|
||||||
data-testid="button-browse"
|
data-testid="button-browse"
|
||||||
>
|
>
|
||||||
<CloudUpload className="w-4 h-4 mr-2" />
|
<CloudUpload className="w-4 h-4" />
|
||||||
Parcourir
|
Parcourir
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ export function PreviewModal({
|
|||||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
||||||
data-testid="button-publish-from-preview"
|
data-testid="button-publish-from-preview"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4" />
|
||||||
{isPublishing ? 'Publication...' : 'Publier'}
|
{isPublishing ? 'Publication...' : 'Publier'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function QwenSettingsCard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card>
|
||||||
<CardHeader className="p-6">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Cpu className="w-5 h-5" />
|
<Cpu className="w-5 h-5" />
|
||||||
Qwen TTS (voix locale)
|
Qwen TTS (voix locale)
|
||||||
@@ -113,12 +113,12 @@ export function QwenSettingsCard() {
|
|||||||
data-testid="input-qwen-api-key"
|
data-testid="input-qwen-api-key"
|
||||||
/>
|
/>
|
||||||
{config?.hasApiKey && (
|
{config?.hasApiKey && (
|
||||||
<p className="text-xs text-green-600 dark:text-green-400">✓ Clé enregistrée (laisser vide pour la garder)</p>
|
<p className="text-xs text-success">✓ Clé enregistrée (laisser vide pour la garder)</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{test && (
|
{test && (
|
||||||
<p className={`text-sm ${test.ok ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
<p className={`text-sm ${test.ok ? "text-success" : "text-destructive"}`}>
|
||||||
{test.ok
|
{test.ok
|
||||||
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
|
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
|
||||||
: `✗ ${test.error}`}
|
: `✗ ${test.error}`}
|
||||||
@@ -133,7 +133,7 @@ export function QwenSettingsCard() {
|
|||||||
disabled={runTest.isPending || (!url && !config?.url)}
|
disabled={runTest.isPending || (!url && !config?.url)}
|
||||||
data-testid="button-test-qwen"
|
data-testid="button-test-qwen"
|
||||||
>
|
>
|
||||||
{runTest.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
{runTest.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
Tester la connexion
|
Tester la connexion
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function SrtUpload({ value, onChange }: SrtUploadProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
|
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4" />
|
||||||
Importer un fichier SRT
|
Importer un fichier SRT
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -223,11 +223,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
) : playing ? (
|
) : playing ? (
|
||||||
<Square className="w-3 h-3 mr-2" />
|
<Square className="w-3 h-3" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="w-3 h-3 mr-2" />
|
<Play className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
|
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export default function Sidebar() {
|
|||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
data-testid="button-logout"
|
data-testid="button-logout"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Check, Lock } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface StepDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperProps {
|
||||||
|
steps: StepDef[];
|
||||||
|
current: number;
|
||||||
|
/** Renvoie null si l'étape est accessible, sinon la raison du blocage. */
|
||||||
|
lockedReason?: (index: number) => string | null;
|
||||||
|
onStepClick?: (index: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indicateur d'étapes cliquable, utilisé par les parcours de création. */
|
||||||
|
export function Stepper({ steps, current, lockedReason, onStepClick, className }: StepperProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Étapes" className={cn("mb-6", className)}>
|
||||||
|
{/* Mobile : libellé de l'étape + barres de progression */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Étape {current + 1} sur {steps.length}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">{steps[current]?.label}</p>
|
||||||
|
<div className="mt-2 flex gap-1.5">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={s.label}
|
||||||
|
onClick={() => !lockedReason?.(i) && onStepClick?.(i)}
|
||||||
|
className={cn("h-1.5 flex-1 rounded-full transition-colors", i <= current ? "bg-primary" : "bg-muted")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop */}
|
||||||
|
<ol className="hidden items-center sm:flex">
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
const done = i < current;
|
||||||
|
const active = i === current;
|
||||||
|
const reason = lockedReason?.(i) ?? null;
|
||||||
|
const button = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!reason}
|
||||||
|
onClick={() => onStepClick?.(i)}
|
||||||
|
aria-current={active ? "step" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
active && "text-foreground",
|
||||||
|
done && "text-foreground hover:bg-accent",
|
||||||
|
!active && !done && !reason && "text-muted-foreground hover:bg-accent",
|
||||||
|
reason && "cursor-not-allowed text-muted-foreground/60",
|
||||||
|
)}
|
||||||
|
data-testid={`step-${step.id}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs transition-colors",
|
||||||
|
active && "border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/30",
|
||||||
|
done && "border-primary/30 bg-primary/10 text-primary",
|
||||||
|
!active && !done && "bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{done ? <Check className="h-4 w-4" /> : reason ? <Lock className="h-3.5 w-3.5" /> : <Icon className="h-4 w-4" />}
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:inline">{step.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={step.id} className={cn("flex items-center", i < steps.length - 1 && "flex-1")}>
|
||||||
|
{reason ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span tabIndex={0}>{button}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{reason}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
)}
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div className={cn("mx-2 h-px flex-1", i < current ? "bg-primary/40" : "bg-border")} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Barre Précédent / Suivant, collante en bas sur mobile (au-dessus de la navigation). */
|
||||||
|
export function StepNavigation({
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
nextLabel = "Continuer",
|
||||||
|
nextDisabled,
|
||||||
|
backDisabled,
|
||||||
|
next,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
onBack?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
nextLabel?: string;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
backDisabled?: boolean;
|
||||||
|
/** Remplace le bouton « Continuer » (ex. bouton de publication). */
|
||||||
|
next?: React.ReactNode;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky bottom-16 z-20 -mx-4 mt-6 border-t bg-background/95 px-4 py-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:py-0 sm:backdrop-blur-none lg:bottom-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={backDisabled}
|
||||||
|
className="inline-flex h-10 items-center rounded-lg px-4 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-0"
|
||||||
|
data-testid="button-step-back"
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
{hint && <p className="hidden flex-1 text-right text-xs text-muted-foreground sm:block">{hint}</p>}
|
||||||
|
<div className={cn("flex flex-1 justify-end", hint && "sm:flex-none")}>
|
||||||
|
{next ?? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
|
||||||
|
data-testid="button-step-next"
|
||||||
|
>
|
||||||
|
{nextLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -510,7 +510,7 @@ export default function ImageEditor() {
|
|||||||
onClick={() => saveImageMutation.mutate()}
|
onClick={() => saveImageMutation.mutate()}
|
||||||
disabled={saveImageMutation.isPending}
|
disabled={saveImageMutation.isPending}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4" />
|
||||||
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+497
-318
@@ -3,7 +3,14 @@ import { Page } from "@/components/layout/app-shell";
|
|||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
|
import { Send, Sparkles, Image as ImageIcon, Calendar, CalendarClock, Upload, Camera, GripVertical, Loader2, PenSquare, Type, Users, ChevronDown, Check, Link2, LayoutGrid, Smartphone, Layers, AlertTriangle, Zap } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Stepper, StepNavigation, type StepDef } from "@/components/stepper";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -25,10 +32,8 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
@@ -68,7 +73,7 @@ function SortableMediaItem({
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
|
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
|
||||||
? 'border-primary ring-2 ring-primary'
|
? 'border-primary'
|
||||||
: 'border-transparent hover:border-muted-foreground'
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -86,15 +91,15 @@ function SortableMediaItem({
|
|||||||
</button>
|
</button>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-1 right-1 w-6 h-6 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-bold">
|
<div className="absolute top-1 right-1 w-5 h-5 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-bold">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="absolute top-1 left-1 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center cursor-move hover:bg-black/70 transition-colors"
|
className="absolute top-1 left-1 w-7 h-7 bg-background/80 backdrop-blur rounded-full flex items-center justify-center cursor-move hover:bg-background transition-colors"
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4 text-white" />
|
<GripVertical className="w-4 h-4 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -112,6 +117,28 @@ function startOfToday(): Date {
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STEPS: StepDef[] = [
|
||||||
|
{ id: "media", label: "Médias", icon: ImageIcon },
|
||||||
|
{ id: "text", label: "Texte", icon: Type },
|
||||||
|
{ id: "targets", label: "Diffusion", icon: Users },
|
||||||
|
{ id: "schedule", label: "Planification", icon: Calendar },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMATS = [
|
||||||
|
{ value: 'feed', label: "Fil d'actualité", description: "Publication classique", icon: LayoutGrid },
|
||||||
|
{ value: 'story', label: "Story", description: "Visible 24 h, média requis", icon: Smartphone },
|
||||||
|
{ value: 'both', label: "Fil + Story", description: "Les deux à la fois", icon: Layers },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function SummaryRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className={`text-right font-medium ${ok ? 'text-foreground' : 'text-muted-foreground'}`}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewPost() {
|
export default function NewPost() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -125,6 +152,9 @@ export default function NewPost() {
|
|||||||
const [postText, setPostText] = useState('');
|
const [postText, setPostText] = useState('');
|
||||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [aiOpen, setAiOpen] = useState(false);
|
||||||
|
const [scheduleMode, setScheduleMode] = useState<'now' | 'later'>('now');
|
||||||
|
|
||||||
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
||||||
// vidéos et se pilote depuis la création de reel.
|
// vidéos et se pilote depuis la création de reel.
|
||||||
@@ -321,10 +351,6 @@ export default function NewPost() {
|
|||||||
|
|
||||||
const handleUseVariant = (text: string) => {
|
const handleUseVariant = (text: string) => {
|
||||||
setPostText(text);
|
setPostText(text);
|
||||||
toast({
|
|
||||||
title: "Texte sélectionné",
|
|
||||||
description: "Le texte a été ajouté à votre publication",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -383,95 +409,141 @@ export default function NewPost() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contentReady = postText.trim().length > 0 || selectedMedia.length > 0;
|
||||||
|
const needsMedia = postType === 'story' || postType === 'both';
|
||||||
|
const targetsReady = selectedPages.length > 0 && (!needsMedia || selectedMedia.length > 0);
|
||||||
|
|
||||||
|
const lockedReason = (index: number): string | null => {
|
||||||
|
if (index >= 2 && !contentReady) return "Ajoutez un média ou un texte";
|
||||||
|
if (index >= 3 && !targetsReady) return selectedPages.length === 0 ? "Choisissez au moins une page" : "Les stories nécessitent un média";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goTo = (index: number) => {
|
||||||
|
const reason = lockedReason(index);
|
||||||
|
if (reason) {
|
||||||
|
toast({ title: "Étape incomplète", description: reason, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(index);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLabel = postType === 'feed' ? "Fil d'actualité" : postType === 'story' ? "Story" : "Fil + Story";
|
||||||
|
const selectedPageNames = pages.filter(p => selectedPages.includes(p.id)).map(p => p.pageName);
|
||||||
|
|
||||||
|
const toggleMedia = (mediaId: string) => {
|
||||||
|
if (selectedMedia.includes(mediaId)) {
|
||||||
|
setSelectedMedia(prev => prev.filter(id => id !== mediaId));
|
||||||
|
} else if (selectedMedia.length < 10) {
|
||||||
|
setSelectedMedia(prev => [...prev, mediaId]);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Limite atteinte",
|
||||||
|
description: "Maximum 10 médias par publication",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Page width="default">
|
<Page width="default">
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1>
|
icon={PenSquare}
|
||||||
<p className="text-muted-foreground mt-2">
|
title="Nouvelle publication"
|
||||||
Créez et planifiez une nouvelle publication
|
description="Préparez une publication photo ou vidéo pour Facebook et Instagram."
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 lg:gap-8">
|
<Stepper steps={STEPS} current={step} lockedReason={lockedReason} onStepClick={goTo} />
|
||||||
<div className="space-y-8">
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<CardHeader className="p-6">
|
<div className="min-w-0 lg:col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<input
|
||||||
<div>
|
ref={cameraInputRef}
|
||||||
<CardTitle>Média</CardTitle>
|
type="file"
|
||||||
<CardDescription>Sélectionnez ou uploadez une image/vidéo</CardDescription>
|
accept="image/*,video/*"
|
||||||
</div>
|
capture="environment"
|
||||||
<div className="flex gap-2">
|
onChange={handleCameraCapture}
|
||||||
<Button
|
className="hidden"
|
||||||
onClick={() => cameraInputRef.current?.click()}
|
/>
|
||||||
disabled={uploadMutation.isPending}
|
|
||||||
size="sm"
|
{/* ÉTAPE 1 : Médias */}
|
||||||
variant="outline"
|
{step === 0 && (
|
||||||
className="lg:hidden"
|
<Card className="fade-in">
|
||||||
data-testid="button-camera-capture"
|
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||||
>
|
<div>
|
||||||
<Camera className="w-4 h-4" />
|
<CardTitle>Choisissez vos médias</CardTitle>
|
||||||
</Button>
|
<CardDescription>Jusqu'à 10 photos ou vidéos. Facultatif pour une publication texte.</CardDescription>
|
||||||
<Button
|
</div>
|
||||||
onClick={open}
|
<div className="flex shrink-0 gap-2">
|
||||||
disabled={uploadMutation.isPending}
|
<Button
|
||||||
size="sm"
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
variant="outline"
|
disabled={uploadMutation.isPending}
|
||||||
data-testid="button-upload-new-media"
|
size="icon"
|
||||||
>
|
variant="outline"
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
className="lg:hidden"
|
||||||
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
|
aria-label="Prendre une photo"
|
||||||
</Button>
|
data-testid="button-camera-capture"
|
||||||
</div>
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={open}
|
||||||
|
disabled={uploadMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
data-testid="button-upload-new-media"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Importer
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<input
|
<div
|
||||||
ref={cameraInputRef}
|
{...getRootProps()}
|
||||||
type="file"
|
className={`rounded-xl transition-colors ${isDragActive ? 'bg-primary/5 ring-2 ring-primary ring-offset-2 ring-offset-card' : ''}`}
|
||||||
accept="image/*,video/*"
|
>
|
||||||
capture="environment"
|
|
||||||
onChange={handleCameraCapture}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
|
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
{mediaList.length === 0 ? (
|
{mediaList.length === 0 ? (
|
||||||
<div className="text-center py-8 border-2 border-dashed rounded-lg">
|
<EmptyState
|
||||||
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
compact
|
||||||
<p className="text-muted-foreground mb-2">
|
icon={ImageIcon}
|
||||||
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
|
title={isDragActive ? "Déposez votre fichier ici" : "Aucun média pour l'instant"}
|
||||||
</p>
|
description="Glissez-déposez une image ou une vidéo, ou importez-la depuis votre appareil."
|
||||||
<div className="flex gap-2 justify-center">
|
action={
|
||||||
<Button
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
onClick={() => cameraInputRef.current?.click()}
|
<Button
|
||||||
disabled={uploadMutation.isPending}
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
size="sm"
|
disabled={uploadMutation.isPending}
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="lg:hidden"
|
variant="outline"
|
||||||
data-testid="button-camera-first"
|
className="lg:hidden"
|
||||||
>
|
data-testid="button-camera-first"
|
||||||
<Camera className="w-4 h-4 mr-2" />
|
>
|
||||||
Prendre une photo
|
<Camera className="w-4 h-4" />
|
||||||
</Button>
|
Prendre une photo
|
||||||
<Button
|
</Button>
|
||||||
onClick={open}
|
<Button
|
||||||
disabled={uploadMutation.isPending}
|
onClick={open}
|
||||||
size="sm"
|
disabled={uploadMutation.isPending}
|
||||||
data-testid="button-upload-first-media"
|
size="sm"
|
||||||
>
|
data-testid="button-upload-first-media"
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
>
|
||||||
Uploader une photo
|
<Upload className="w-4 h-4" />
|
||||||
</Button>
|
Importer un fichier
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{selectedMedia.length > 0 && (
|
{selectedMedia.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-5">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
Photos sélectionnées ({selectedMedia.length}/10)
|
<p className="text-sm font-medium">Sélection ({selectedMedia.length}/10)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Glissez pour réordonner</p>
|
||||||
</div>
|
</div>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -482,7 +554,7 @@ export default function NewPost() {
|
|||||||
items={selectedMedia}
|
items={selectedMedia}
|
||||||
strategy={rectSortingStrategy}
|
strategy={rectSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-2 p-3 bg-accent/20 rounded-lg border border-accent">
|
<div className="grid grid-cols-4 gap-2 rounded-lg border border-primary/20 bg-primary/5 p-2 sm:grid-cols-5">
|
||||||
{selectedMedia.map((mediaId, index) => {
|
{selectedMedia.map((mediaId, index) => {
|
||||||
const media = mediaList.find(m => m.id === mediaId);
|
const media = mediaList.find(m => m.id === mediaId);
|
||||||
if (!media) return null;
|
if (!media) return null;
|
||||||
@@ -504,252 +576,361 @@ export default function NewPost() {
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
<p className="mb-2 text-sm font-medium">Médias récents</p>
|
||||||
Toutes les photos ({mediaList.length})
|
<div className="grid max-h-[460px] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4">
|
||||||
</div>
|
{mediaList.map((media) => {
|
||||||
<div className="max-h-[500px] overflow-y-auto">
|
const isSelected = selectedMedia.includes(media.id);
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
const isVideo = media.type === 'video';
|
||||||
{mediaList.map((media) => {
|
const order = selectedMedia.indexOf(media.id);
|
||||||
const isSelected = selectedMedia.includes(media.id);
|
|
||||||
const isVideo = media.type === 'video';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={media.id}
|
key={media.id}
|
||||||
onClick={() => {
|
onClick={() => toggleMedia(media.id)}
|
||||||
if (isSelected) {
|
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-all ${isSelected
|
||||||
// Remove from selection
|
? 'border-primary'
|
||||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
: 'border-transparent hover:border-primary/40'
|
||||||
} else if (selectedMedia.length < 10) {
|
}`}
|
||||||
// Add to selection (max 10)
|
aria-pressed={isSelected}
|
||||||
setSelectedMedia(prev => [...prev, media.id]);
|
data-testid={`button-select-media-${media.id}`}
|
||||||
} else {
|
>
|
||||||
toast({
|
<MediaThumbnail
|
||||||
title: "Limite atteinte",
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
description: "Maximum 10 photos par publication",
|
alt={media.fileName}
|
||||||
variant: "destructive",
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
});
|
type={isVideo ? 'video' : 'image'}
|
||||||
}
|
/>
|
||||||
}}
|
{isSelected && (
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
|
<div className="absolute inset-0 bg-primary/20">
|
||||||
? 'border-primary ring-2 ring-primary opacity-50'
|
<span className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow">
|
||||||
: 'border-transparent hover:border-muted-foreground'
|
{order + 1}
|
||||||
}`}
|
</span>
|
||||||
data-testid={`button-select-media-${media.id}`}
|
</div>
|
||||||
>
|
)}
|
||||||
<MediaThumbnail
|
</button>
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
);
|
||||||
alt={media.fileName}
|
})}
|
||||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
|
||||||
type={isVideo ? 'video' : 'image'}
|
|
||||||
/>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
|
||||||
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
{/* ÉTAPE 2 : Texte */}
|
||||||
<CardHeader className="p-6">
|
{step === 1 && (
|
||||||
<CardTitle>Contenu</CardTitle>
|
<div className="fade-in space-y-6">
|
||||||
<CardDescription>Informations du produit ou message</CardDescription>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardTitle>Rédigez votre texte</CardTitle>
|
||||||
<div>
|
<CardDescription>Écrivez-le vous-même ou laissez l'IA vous proposer des versions.</CardDescription>
|
||||||
<Label htmlFor="productInfo">Informations du produit</Label>
|
|
||||||
<Textarea
|
|
||||||
id="productInfo"
|
|
||||||
value={productInfo}
|
|
||||||
onChange={(e) => setProductInfo(e.target.value)}
|
|
||||||
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
|
|
||||||
rows={6}
|
|
||||||
data-testid="input-product-info"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerateText}
|
|
||||||
disabled={generateTextMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
data-testid="button-generate-text"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{generatedVariants.length > 0 && (
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="p-6">
|
|
||||||
<CardTitle>Variations générées par l'IA</CardTitle>
|
|
||||||
<CardDescription>Cliquez sur "Utiliser" pour remplir le texte ci-dessous</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
{generatedVariants.map((variant, index) => (
|
<Collapsible open={aiOpen} onOpenChange={setAiOpen} className="rounded-xl border border-primary/20 bg-primary/5">
|
||||||
<div
|
<CollapsibleTrigger className="flex w-full items-center gap-3 p-3 text-left" data-testid="toggle-ai-assist">
|
||||||
key={index}
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
<Sparkles className="h-4 w-4" />
|
||||||
>
|
</span>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<span className="flex-1">
|
||||||
<div className="flex-1">
|
<span className="block text-sm font-medium">Générer avec l'IA</span>
|
||||||
<div className="text-xs font-semibold text-muted-foreground mb-2">
|
<span className="block text-xs text-muted-foreground">Décrivez votre produit, l'IA rédige 3 propositions</span>
|
||||||
{variant.variant || `Version ${index + 1}`}
|
</span>
|
||||||
</div>
|
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${aiOpen ? 'rotate-180' : ''}`} />
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
</CollapsibleTrigger>
|
||||||
{variant.text}
|
<CollapsibleContent className="space-y-3 px-3 pb-3">
|
||||||
</p>
|
<Textarea
|
||||||
|
id="productInfo"
|
||||||
|
value={productInfo}
|
||||||
|
onChange={(e) => setProductInfo(e.target.value)}
|
||||||
|
placeholder="Ex. : Produit : Chaise en rotin — Prix : 49 € — Caractéristiques : légère, résistante"
|
||||||
|
rows={4}
|
||||||
|
className="bg-card"
|
||||||
|
data-testid="input-product-info"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateText}
|
||||||
|
disabled={generateTextMutation.isPending}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
data-testid="button-generate-text"
|
||||||
|
>
|
||||||
|
{generateTextMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{generateTextMutation.isPending ? 'Génération…' : 'Générer des propositions'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{generatedVariants.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
{generatedVariants.map((variant, index) => (
|
||||||
|
<div key={index} className="rounded-lg border bg-card p-3">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{variant.variant || `Proposition ${index + 1}`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={postText === variant.text ? "secondary" : "default"}
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => handleUseVariant(variant.text)}
|
||||||
|
data-testid={`button-use-variant-${index}`}
|
||||||
|
>
|
||||||
|
{postText === variant.text ? <><Check className="h-3.5 w-3.5" /> Utilisée</> : "Utiliser"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">{variant.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
)}
|
||||||
size="sm"
|
</CollapsibleContent>
|
||||||
onClick={() => handleUseVariant(variant.text)}
|
</Collapsible>
|
||||||
data-testid={`button-use-variant-${index}`}
|
|
||||||
>
|
<div className="space-y-2">
|
||||||
Utiliser
|
<div className="flex items-center justify-between">
|
||||||
</Button>
|
<Label htmlFor="post-text">Texte de la publication</Label>
|
||||||
</div>
|
<span className="text-xs tabular-nums text-muted-foreground">{postText.length} caractères</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Textarea
|
||||||
|
id="post-text"
|
||||||
|
value={postText}
|
||||||
|
onChange={(e) => setPostText(e.target.value)}
|
||||||
|
placeholder="Écrivez votre texte ici…"
|
||||||
|
rows={9}
|
||||||
|
data-testid="textarea-post-text"
|
||||||
|
/>
|
||||||
|
{selectedMedia.length > 0 && !postText.trim() && (
|
||||||
|
<p className="text-xs text-muted-foreground">Le texte est facultatif puisque vous avez ajouté un média.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
{/* ÉTAPE 3 : Diffusion */}
|
||||||
<CardHeader className="p-6">
|
{step === 2 && (
|
||||||
<CardTitle>Texte de la publication</CardTitle>
|
<div className="fade-in space-y-6">
|
||||||
<CardDescription>Écrivez ou générez le texte de votre publication</CardDescription>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Où publier ?</CardTitle>
|
||||||
<Textarea
|
<CardDescription>Sélectionnez une ou plusieurs pages.</CardDescription>
|
||||||
value={postText}
|
</CardHeader>
|
||||||
onChange={(e) => setPostText(e.target.value)}
|
<CardContent>
|
||||||
placeholder="Écrivez votre texte ici ou générez-le avec l'IA..."
|
{pages.length === 0 ? (
|
||||||
rows={8}
|
<EmptyState
|
||||||
data-testid="textarea-post-text"
|
compact
|
||||||
/>
|
icon={Link2}
|
||||||
</CardContent>
|
title="Aucune page connectée"
|
||||||
</Card>
|
description="Connectez une page Facebook ou Instagram pour pouvoir publier."
|
||||||
</div>
|
action={
|
||||||
|
<Button size="sm" onClick={() => navigate('/pages')} data-testid="link-add-pages">
|
||||||
|
Connecter un compte
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{pages.map((page) => {
|
||||||
|
const checked = selectedPages.includes(page.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={page.id}
|
||||||
|
htmlFor={`page-${page.id}`}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${checked ? 'border-primary bg-primary/5' : 'hover:bg-accent'}`}
|
||||||
|
>
|
||||||
|
<PlatformIcon platform={page.platform} size="sm" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium">{page.pageName}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{platformLabel(page.platform)}</span>
|
||||||
|
</span>
|
||||||
|
<Checkbox
|
||||||
|
id={`page-${page.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setSelectedPages([...selectedPages, page.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid={`checkbox-page-${page.id}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<Card>
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<CardHeader>
|
||||||
<CardHeader className="p-6">
|
<CardTitle>Format</CardTitle>
|
||||||
<CardTitle>Pages cibles</CardTitle>
|
<CardDescription>Comment la publication apparaîtra.</CardDescription>
|
||||||
<CardDescription>Sélectionnez les pages où publier</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<RadioGroup
|
||||||
{pages.length === 0 ? (
|
value={postType}
|
||||||
<div className="text-center py-8">
|
onValueChange={(value) => setPostType(value as 'feed' | 'story' | 'both')}
|
||||||
<p className="text-muted-foreground mb-2">Aucune page connectée</p>
|
className="grid grid-cols-1 gap-2 sm:grid-cols-3"
|
||||||
<Button
|
data-testid="select-post-type"
|
||||||
variant="link"
|
>
|
||||||
onClick={() => navigate('/pages')}
|
{FORMATS.map(({ value, label, description, icon: Icon }) => (
|
||||||
data-testid="link-add-pages"
|
<label
|
||||||
>
|
key={value}
|
||||||
Ajouter des pages
|
htmlFor={`format-${value}`}
|
||||||
</Button>
|
className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors sm:flex-col ${postType === value ? 'border-primary bg-primary/5' : 'hover:bg-accent'}`}
|
||||||
</div>
|
data-testid={`option-${value}`}
|
||||||
) : (
|
>
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-between">
|
||||||
{pages.map((page) => (
|
<Icon className={`h-5 w-5 ${postType === value ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
<div key={page.id} className="flex items-center space-x-2">
|
<RadioGroupItem value={value} id={`format-${value}`} className="sr-only sm:not-sr-only" />
|
||||||
<Checkbox
|
</div>
|
||||||
id={`page-${page.id}`}
|
<div>
|
||||||
checked={selectedPages.includes(page.id)}
|
<p className="text-sm font-medium">{label}</p>
|
||||||
onCheckedChange={(checked) => {
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
if (checked) {
|
</div>
|
||||||
setSelectedPages([...selectedPages, page.id]);
|
</label>
|
||||||
} else {
|
|
||||||
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid={`checkbox-page-${page.id}`}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`page-${page.id}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
|
|
||||||
>
|
|
||||||
{page.pageName} ({page.platform})
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
{needsMedia && selectedMedia.length === 0 && (
|
||||||
|
<p className="mt-3 flex items-center gap-2 rounded-lg bg-warning/10 px-3 py-2 text-xs text-warning-foreground dark:text-warning">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
Une story nécessite au moins une image ou vidéo : revenez à l'étape Médias.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ÉTAPE 4 : Planification */}
|
||||||
|
{step === 3 && (
|
||||||
|
<Card className="fade-in">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quand publier ?</CardTitle>
|
||||||
|
<CardDescription>Publiez tout de suite ou choisissez une date.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<RadioGroup
|
||||||
|
value={scheduleMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setScheduleMode(value as 'now' | 'later');
|
||||||
|
if (value === 'now') setScheduledDate(undefined);
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
{([
|
||||||
|
{ value: 'now', label: 'Publier maintenant', description: 'Envoi immédiat après validation', icon: Zap },
|
||||||
|
{ value: 'later', label: 'Programmer', description: 'Choisir une date et une heure', icon: CalendarClock },
|
||||||
|
] as const).map(({ value, label, description, icon: Icon }) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
htmlFor={`when-${value}`}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors ${scheduleMode === value ? 'border-primary bg-primary/5' : 'hover:bg-accent'}`}
|
||||||
|
data-testid={`option-schedule-${value}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-5 w-5 ${scheduleMode === value ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium">{label}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||||
|
</span>
|
||||||
|
<RadioGroupItem value={value} id={`when-${value}`} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{scheduleMode === 'later' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Date et heure</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={setScheduledDate}
|
||||||
|
occupiedDates={scheduledPosts
|
||||||
|
.filter(post => post.scheduledAt)
|
||||||
|
.map(post => new Date(post.scheduledAt!))}
|
||||||
|
placeholder="Choisir une date"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Les créneaux déjà occupés sont signalés dans le calendrier.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<StepNavigation
|
||||||
<CardHeader className="p-6">
|
onBack={() => goTo(step - 1)}
|
||||||
<CardTitle>Type de publication</CardTitle>
|
backDisabled={step === 0}
|
||||||
<CardDescription>Choisissez où publier</CardDescription>
|
onNext={() => goTo(step + 1)}
|
||||||
</CardHeader>
|
nextLabel={step === 0 && selectedMedia.length === 0 ? "Continuer sans média" : "Continuer"}
|
||||||
<CardContent>
|
next={
|
||||||
<div className="space-y-2">
|
step === STEPS.length - 1 ? (
|
||||||
<Label htmlFor="postType">Format</Label>
|
<Button
|
||||||
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
|
onClick={() => {
|
||||||
<SelectTrigger id="postType" data-testid="select-post-type">
|
if (scheduleMode === 'later' && !scheduledDate) {
|
||||||
<SelectValue />
|
toast({ title: "Date manquante", description: "Choisissez une date ou publiez maintenant.", variant: "destructive" });
|
||||||
</SelectTrigger>
|
return;
|
||||||
<SelectContent>
|
}
|
||||||
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem>
|
setPreviewModalOpen(true);
|
||||||
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem>
|
}}
|
||||||
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem>
|
disabled={createPostMutation.isPending}
|
||||||
</SelectContent>
|
variant="brand"
|
||||||
</Select>
|
className="w-full sm:w-auto"
|
||||||
<p className="text-xs text-muted-foreground">
|
data-testid="button-preview-post"
|
||||||
{postType === 'feed' && 'Publication classique dans le fil d\'actualité'}
|
>
|
||||||
{postType === 'story' && 'Story éphémère (24h) - Nécessite un média'}
|
<Send className="w-4 h-4" />
|
||||||
{postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'}
|
Prévisualiser et {scheduleMode === 'later' ? 'programmer' : 'publier'}
|
||||||
</p>
|
</Button>
|
||||||
</div>
|
) : undefined
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="p-6">
|
|
||||||
<CardTitle>Planification</CardTitle>
|
|
||||||
<CardDescription>Programmez la publication (optionnel)</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Date et heure</Label>
|
|
||||||
<DateTimePicker
|
|
||||||
value={scheduledDate}
|
|
||||||
onChange={setScheduledDate}
|
|
||||||
occupiedDates={scheduledPosts
|
|
||||||
.filter(post => post.scheduledAt)
|
|
||||||
.map(post => new Date(post.scheduledAt!))}
|
|
||||||
placeholder="Publier immédiatement"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Sélectionnez une date pour planifier, sinon publication immédiate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setPreviewModalOpen(true)}
|
|
||||||
disabled={createPostMutation.isPending}
|
|
||||||
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg"
|
|
||||||
size="lg"
|
|
||||||
data-testid="button-preview-post"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
Prévisualiser et publier
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<Card className="sticky top-6">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle>Récapitulatif</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm">
|
||||||
|
{selectedMedia.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{selectedMedia.slice(0, 8).map((id) => {
|
||||||
|
const media = mediaList.find(m => m.id === id);
|
||||||
|
if (!media) return null;
|
||||||
|
return (
|
||||||
|
<div key={id} className="aspect-square overflow-hidden rounded-md">
|
||||||
|
<MediaThumbnail
|
||||||
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center rounded-lg border border-dashed text-muted-foreground">
|
||||||
|
<ImageIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{postText.trim() && <p className="line-clamp-4 whitespace-pre-wrap text-muted-foreground">{postText}</p>}
|
||||||
|
<dl className="space-y-2 border-t pt-3">
|
||||||
|
<SummaryRow label="Médias" value={selectedMedia.length ? `${selectedMedia.length} sélectionné(s)` : "Aucun"} ok={selectedMedia.length > 0} />
|
||||||
|
<SummaryRow label="Texte" value={postText.trim() ? `${postText.length} caractères` : "Vide"} ok={!!postText.trim()} />
|
||||||
|
<SummaryRow label="Pages" value={selectedPageNames.length ? selectedPageNames.join(", ") : "Aucune"} ok={selectedPageNames.length > 0} />
|
||||||
|
<SummaryRow label="Format" value={formatLabel} ok />
|
||||||
|
<SummaryRow
|
||||||
|
label="Envoi"
|
||||||
|
value={scheduleMode === 'later' && scheduledDate ? format(scheduledDate, "d MMM 'à' HH:mm", { locale: fr }) : "Immédiat"}
|
||||||
|
ok
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
|
||||||
<Dialog open={uploadMutation.isPending}>
|
<Dialog open={uploadMutation.isPending}>
|
||||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||||
@@ -757,13 +938,11 @@ export default function NewPost() {
|
|||||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
|
||||||
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-1">
|
||||||
Upload en cours...
|
Import en cours…
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
Votre image est en cours de téléchargement et de traitement.
|
Votre fichier est en cours d'envoi et de traitement.
|
||||||
<br />
|
|
||||||
Veuillez patienter.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ export default function NewReel() {
|
|||||||
disabled={uploadMutation.isPending}
|
disabled={uploadMutation.isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4" />
|
||||||
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
|
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -717,7 +717,7 @@ export default function NewReel() {
|
|||||||
disabled={generateTextMutation.isPending}
|
disabled={generateTextMutation.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
<Sparkles className="w-4 h-4" />
|
||||||
{generateTextMutation.isPending ? 'Génération...' : 'Générer 3 variations'}
|
{generateTextMutation.isPending ? 'Génération...' : 'Générer 3 variations'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -976,12 +976,12 @@ export default function NewReel() {
|
|||||||
>
|
>
|
||||||
{createReelMutation.isPending ? (
|
{createReelMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Création...
|
Création...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4" />
|
||||||
Publier le Reel
|
Publier le Reel
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -183,15 +183,15 @@ export default function PagesManagement() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{page.platform === "tiktok" ? (
|
{page.platform === "tiktok" ? (
|
||||||
<DropdownMenuItem onClick={() => { window.location.href = "/api/tiktok/connect"; }} data-testid={`button-reconnect-page-${page.id}`}>
|
<DropdownMenuItem onClick={() => { window.location.href = "/api/tiktok/connect"; }} data-testid={`button-reconnect-page-${page.id}`}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" /> Reconnecter
|
<RefreshCw className="h-4 w-4" /> Reconnecter
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem onClick={() => setEditingPage(page)} data-testid={`button-edit-page-${page.id}`}>
|
<DropdownMenuItem onClick={() => setEditingPage(page)} data-testid={`button-edit-page-${page.id}`}>
|
||||||
<KeyRound className="mr-2 h-4 w-4" /> Remplacer le jeton
|
<KeyRound className="h-4 w-4" /> Remplacer le jeton
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.open(`https://developers.facebook.com/tools/debug/accesstoken/`, "_blank")} data-testid="button-debug-token">
|
<DropdownMenuItem onClick={() => window.open(`https://developers.facebook.com/tools/debug/accesstoken/`, "_blank")} data-testid="button-debug-token">
|
||||||
<Bug className="mr-2 h-4 w-4" /> Déboguer le jeton
|
<Bug className="h-4 w-4" /> Déboguer le jeton
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -201,7 +201,7 @@ export default function PagesManagement() {
|
|||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
data-testid={`button-delete-page-${page.id}`}
|
data-testid={`button-delete-page-${page.id}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> Déconnecter
|
<Trash2 className="h-4 w-4" /> Déconnecter
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -269,9 +269,9 @@ export default function ScheduleReel() {
|
|||||||
data-testid="button-schedule-reel"
|
data-testid="button-schedule-reel"
|
||||||
>
|
>
|
||||||
{scheduleMutation.isPending ? (
|
{scheduleMutation.isPending ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CalendarClock className="w-4 h-4 mr-2" />
|
<CalendarClock className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
Programmer le Reel
|
Programmer le Reel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+273
-249
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Page } from "@/components/layout/app-shell";
|
import { Page } from "@/components/layout/app-shell";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook, Share2, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -15,7 +16,27 @@ import { ModelCombobox } from "@/components/model-combobox";
|
|||||||
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
|
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
|
||||||
import { SiTiktok } from "react-icons/si";
|
import { SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
|
const SETTINGS_TABS = [
|
||||||
|
{ value: "general", label: "Général", icon: SlidersHorizontal },
|
||||||
|
{ value: "ai", label: "Intelligence artificielle", icon: Brain },
|
||||||
|
{ value: "media", label: "Stockage & vidéo", icon: Cloud },
|
||||||
|
{ value: "social", label: "Réseaux sociaux", icon: Share2 },
|
||||||
|
{ value: "api", label: "API externe", icon: Plug },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SettingsTab = (typeof SETTINGS_TABS)[number]["value"];
|
||||||
|
|
||||||
|
function initialTab(): SettingsTab {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
return (SETTINGS_TABS.find((t) => t.value === hash)?.value ?? "general") as SettingsTab;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const [tab, setTab] = useState<SettingsTab>(initialTab);
|
||||||
|
const changeTab = (value: string) => {
|
||||||
|
setTab(value as SettingsTab);
|
||||||
|
window.history.replaceState(null, "", `#${value}`);
|
||||||
|
};
|
||||||
const [notifications, setNotifications] = useState(true);
|
const [notifications, setNotifications] = useState(true);
|
||||||
const [autoPublish, setAutoPublish] = useState(true);
|
const [autoPublish, setAutoPublish] = useState(true);
|
||||||
const [cloudName, setCloudName] = useState("");
|
const [cloudName, setCloudName] = useState("");
|
||||||
@@ -418,16 +439,31 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page width="default">
|
<Page width="default">
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
|
icon={SettingsIcon}
|
||||||
<p className="text-muted-foreground mt-2">
|
title="Paramètres"
|
||||||
Configurez votre application
|
description="Configurez les services utilisés par Social Flow."
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<Tabs value={tab} onValueChange={changeTab} orientation="vertical" className="lg:grid lg:grid-cols-[220px_1fr] lg:gap-8">
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<TabsList className="mb-6 flex h-auto w-full justify-start gap-1 overflow-x-auto bg-muted p-1 scrollbar-none lg:sticky lg:top-6 lg:mb-0 lg:h-fit lg:flex-col lg:items-stretch lg:bg-transparent lg:p-0">
|
||||||
<CardHeader className="p-6">
|
{SETTINGS_TABS.map(({ value, label, icon: Icon }) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
className="shrink-0 gap-2 lg:justify-start lg:px-3 lg:py-2 lg:data-[state=active]:bg-primary/10 lg:data-[state=active]:text-primary lg:data-[state=active]:shadow-none"
|
||||||
|
data-testid={`tab-settings-${value}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<TabsContent value="general" className="mt-0 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
Notifications
|
Notifications
|
||||||
@@ -468,8 +504,109 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
{isAdmin && (
|
||||||
<CardHeader className="p-6">
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Image className="w-5 h-5" />
|
||||||
|
Logo de l'entreprise
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Uploadez votre logo pour l'ajouter aux images éditées
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{logoPreview ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative w-full max-w-xs mx-auto">
|
||||||
|
<img
|
||||||
|
src={logoPreview}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8"
|
||||||
|
onClick={() => deleteLogoMutation.mutate()}
|
||||||
|
disabled={deleteLogoMutation.isPending}
|
||||||
|
data-testid="button-delete-logo"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={uploadLogoMutation.isPending}
|
||||||
|
data-testid="button-replace-logo"
|
||||||
|
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
||||||
|
</Button>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
id="logo-upload-replace"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleLogoChange(e);
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
uploadLogoMutation.mutate(e.target.files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||||
|
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
||||||
|
</p>
|
||||||
|
<Label htmlFor="logo-upload" className="cursor-pointer">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
||||||
|
data-testid="button-upload-logo"
|
||||||
|
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
||||||
|
</Button>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
id="logo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleLogoChange(e);
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
uploadLogoMutation.mutate(e.target.files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!cloudinaryConfig && (
|
||||||
|
<p className="text-xs text-warning-foreground dark:text-warning text-center">
|
||||||
|
Veuillez d'abord configurer MinIO ci-dessus
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="ai" className="mt-0 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Brain className="w-5 h-5" />
|
<Brain className="w-5 h-5" />
|
||||||
Configuration OpenRouter (IA)
|
Configuration OpenRouter (IA)
|
||||||
@@ -492,7 +629,7 @@ export default function Settings() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{hasExistingConfig ? (
|
{hasExistingConfig ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
<span className="text-success">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -549,9 +686,59 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Google Gemini TTS */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card>
|
||||||
<CardHeader className="p-6">
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mic className="w-5 h-5" />
|
||||||
|
Google Gemini TTS
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
||||||
|
<Input
|
||||||
|
id="geminiApiKey"
|
||||||
|
type="password"
|
||||||
|
value={geminiApiKey}
|
||||||
|
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||||
|
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
||||||
|
data-testid="input-gemini-api-key"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasExistingGeminiConfig ? (
|
||||||
|
<span className="text-success">✓ Clé API configurée</span>
|
||||||
|
) : (
|
||||||
|
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveGeminiMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
saveGeminiMutation.isPending ||
|
||||||
|
(!hasExistingGeminiConfig && !geminiApiKey)
|
||||||
|
}
|
||||||
|
data-testid="button-save-gemini"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
||||||
|
{isAdmin && <QwenSettingsCard />}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="media" className="mt-0 space-y-6">
|
||||||
|
{isAdmin && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Cloud className="w-5 h-5" />
|
<Cloud className="w-5 h-5" />
|
||||||
Stockage des médias
|
Stockage des médias
|
||||||
@@ -571,8 +758,8 @@ export default function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card>
|
||||||
<CardHeader className="p-6">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Video className="w-5 h-5" />
|
<Video className="w-5 h-5" />
|
||||||
Configuration FFmpeg API (Reels)
|
Configuration FFmpeg API (Reels)
|
||||||
@@ -609,7 +796,7 @@ export default function Settings() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{hasExistingFfmpegConfig ? (
|
{hasExistingFfmpegConfig ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
<span className="text-success">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Clé de sécurité X-API-Key pour accéder à l'API FFmpeg"
|
"Clé de sécurité X-API-Key pour accéder à l'API FFmpeg"
|
||||||
@@ -630,214 +817,11 @@ export default function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
{/* Google Gemini TTS */}
|
<TabsContent value="social" className="mt-0 space-y-6">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card>
|
||||||
<CardHeader className="p-6">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Mic className="w-5 h-5" />
|
|
||||||
Google Gemini TTS
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
|
||||||
<Input
|
|
||||||
id="geminiApiKey"
|
|
||||||
type="password"
|
|
||||||
value={geminiApiKey}
|
|
||||||
onChange={(e) => setGeminiApiKey(e.target.value)}
|
|
||||||
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
|
||||||
data-testid="input-gemini-api-key"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{hasExistingGeminiConfig ? (
|
|
||||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
|
|
||||||
) : (
|
|
||||||
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveGeminiMutation.mutate()}
|
|
||||||
disabled={
|
|
||||||
saveGeminiMutation.isPending ||
|
|
||||||
(!hasExistingGeminiConfig && !geminiApiKey)
|
|
||||||
}
|
|
||||||
data-testid="button-save-gemini"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
|
||||||
{isAdmin && <QwenSettingsCard />}
|
|
||||||
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="p-6">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Image className="w-5 h-5" />
|
|
||||||
Logo de l'entreprise
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Uploadez votre logo pour l'ajouter aux images éditées
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{logoPreview ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative w-full max-w-xs mx-auto">
|
|
||||||
<img
|
|
||||||
src={logoPreview}
|
|
||||||
alt="Logo"
|
|
||||||
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="destructive"
|
|
||||||
className="absolute top-2 right-2 h-8 w-8"
|
|
||||||
onClick={() => deleteLogoMutation.mutate()}
|
|
||||||
disabled={deleteLogoMutation.isPending}
|
|
||||||
data-testid="button-delete-logo"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={uploadLogoMutation.isPending}
|
|
||||||
data-testid="button-replace-logo"
|
|
||||||
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
|
||||||
</Button>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
id="logo-upload-replace"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
handleLogoChange(e);
|
|
||||||
if (e.target.files?.[0]) {
|
|
||||||
uploadLogoMutation.mutate(e.target.files[0]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
|
||||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
|
||||||
</p>
|
|
||||||
<Label htmlFor="logo-upload" className="cursor-pointer">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
|
||||||
data-testid="button-upload-logo"
|
|
||||||
onClick={() => document.getElementById('logo-upload')?.click()}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
|
||||||
</Button>
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
id="logo-upload"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
handleLogoChange(e);
|
|
||||||
if (e.target.files?.[0]) {
|
|
||||||
uploadLogoMutation.mutate(e.target.files[0]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!cloudinaryConfig && (
|
|
||||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
|
||||||
Veuillez d'abord configurer MinIO ci-dessus
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="p-6">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Plug className="w-5 h-5" />
|
|
||||||
API externe
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{hasExistingExternalApiConfig && (
|
|
||||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
|
||||||
<Key className="w-4 h-4" />
|
|
||||||
<span>Clé API configurée et active</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteExternalApiMutation.mutate()}
|
|
||||||
disabled={deleteExternalApiMutation.isPending}
|
|
||||||
>
|
|
||||||
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="externalApiKey">
|
|
||||||
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="externalApiKey"
|
|
||||||
type="password"
|
|
||||||
value={externalApiKey}
|
|
||||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
|
||||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveExternalApiMutation.mutate()}
|
|
||||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
||||||
<CardHeader className="p-6">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Facebook className="w-5 h-5" />
|
<Facebook className="w-5 h-5" />
|
||||||
Facebook
|
Facebook
|
||||||
@@ -850,7 +834,7 @@ export default function Settings() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{facebookConfig?.configured && (
|
{facebookConfig?.configured && (
|
||||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
|
||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
<span>Application Facebook configurée</span>
|
<span>Application Facebook configurée</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,20 +894,20 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card>
|
||||||
<CardHeader className="p-6">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<SiTiktok className="w-5 h-5" />
|
<SiTiktok className="w-5 h-5" />
|
||||||
TikTok
|
TikTok
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
|
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
|
||||||
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
|
connecter les comptes TikTok de chaque magasin depuis la page « Comptes connectés ».
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{tiktokConfig?.configured && (
|
{tiktokConfig?.configured && (
|
||||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
|
||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
<span>Application TikTok configurée</span>
|
<span>Application TikTok configurée</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -973,24 +957,64 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</TabsContent>
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<TabsContent value="api" className="mt-0 space-y-6">
|
||||||
<CardHeader className="p-6">
|
{isAdmin && (
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Card>
|
||||||
<Shield className="w-5 h-5" />
|
<CardHeader>
|
||||||
Sécurité
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardTitle>
|
<Plug className="w-5 h-5" />
|
||||||
<CardDescription>
|
API externe
|
||||||
Paramètres de sécurité et confidentialité
|
</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription>
|
||||||
</CardHeader>
|
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
||||||
<CardContent>
|
</CardDescription>
|
||||||
<p className="text-sm text-muted-foreground">
|
</CardHeader>
|
||||||
Les paramètres de sécurité avancés seront disponibles prochainement.
|
<CardContent className="space-y-4">
|
||||||
</p>
|
{hasExistingExternalApiConfig && (
|
||||||
</CardContent>
|
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||||
</Card>
|
<div className="flex items-center gap-2 text-sm text-success">
|
||||||
</div>
|
<Key className="w-4 h-4" />
|
||||||
</Page>
|
<span>Clé API configurée et active</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteExternalApiMutation.mutate()}
|
||||||
|
disabled={deleteExternalApiMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="externalApiKey">
|
||||||
|
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="externalApiKey"
|
||||||
|
type="password"
|
||||||
|
value={externalApiKey}
|
||||||
|
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||||
|
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveExternalApiMutation.mutate()}
|
||||||
|
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user