Compare commits

...
Author SHA1 Message Date
Claude b451826ec5 Retrait de l'ancien serveur Node/TypeScript
Le serveur Rust (server/src) le remplace entièrement : routes, services,
migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le
schéma est désormais créé et migré par server/src/migrate.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:49:03 +00:00
Claude a0f800101c Merge commit 'e221940' into claude/intelligent-wright-pr07e9 2026-10-12 17:41:26 +00:00
Claude e221940ee4 Serveur Rust : routes principales et service OpenRouter
Portage des routes définies dans server/routes.ts (hors authentification
et routeurs montés à part) : utilisateurs et permissions, migration des
permissions, paramètres (API externe, Gemini, Qwen et test), console SQL
(résultat au format pg, plusieurs instructions), statistiques, IA
(modèles, génération, historique), médias (envoi, liste paginée avec
X-Total-Count, correction des URL, incrustations, suppression), pistes
audio, posts, publications planifiées et agenda, pages, stockage et logo,
OpenRouter et FFmpeg.

Les incrustations (bandeau, prix, logo) sont rendues en SVG avec resvg et
composées avec image ; l'orientation EXIF est appliquée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:41:04 +00:00
Claude 9f72c626a2 Merge commit 'f45e13d' into claude/intelligent-wright-pr07e9 2026-10-12 17:38:03 +00:00
Claude 66bcbe5edd Merge commit 'efbd6dd' into claude/intelligent-wright-pr07e9 2026-10-12 17:37:38 +00:00
Claude f45e13d8b1 Serveur Rust : publication Facebook, jetons et statistiques
Portage de facebook.ts, facebook_oauth.ts, graph_client.ts,
token_manager.ts, analytics.ts et des routes /api/facebook et
/api/analytics (mêmes chemins, réponses et messages d'erreur).

- Image de story avec texte incrusté : recadrage 1080×1920, bandeau
  translucide, DejaVu Sans gras mesurée avec harfrust, rendu resvg,
  emoji Twemoji (CDN jdecked, l'ancien maxcdn n'existe plus).
- Stories vidéo envoyées en binaire (comme les Reels) au lieu de
  l'en-tête file_url, qui exigeait que Facebook joigne le serveur ;
  la phase FINISH et le traitement de la vidéo sont contrôlés et les
  erreurs remontées à l'utilisateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:37:36 +00:00
Claude 52833317e6 Compose : service remotion-renderer pour le rendu des Reels
Le rendu Remotion tourne dans son propre conteneur Node ; l'application
Rust le joint par REMOTION_RENDERER_URL. Ancien script de pré-bundle
retiré (le service pré-bundle lui-même à la construction).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:37:32 +00:00
Claude efbd6dde39 Serveur Rust : TikTok, planificateur, tâches périodiques, API externe et pages légales
- services/tiktok : OAuth, renouvellement des jetons, creator_info,
  publication par chunks (FILE_UPLOAD) et suivi asynchrone des statuts
- services/scheduler : publication des posts dus chaque minute
  (Facebook via facebook::publish_post, TikTok, rejet des anciens « both »)
- services/cron : suivi TikTok (*/2), jetons (0,12 h), statistiques
  (8,20 h), purge des médias (3 h) et purge horaire des sessions, en UTC
- services/media_purge : images > 30 jours, vidéos > 7 jours
- routes/tiktok (/api/tiktok), routes/external (/api/v1, clé X-API-Key,
  messages de validation identiques à zod), routes/legal (/terms, /privacy,
  HTML identique à l'octet près)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:37:13 +00:00
Claude 2bba946f34 Merge commit 'd2293f8' into claude/intelligent-wright-pr07e9
# Conflicts:
#	server/src/services/ffmpeg.rs
2026-10-12 17:37:13 +00:00
Claude d2293f846e Serveur Rust : Reels (client FFmpeg, file de rendus, Remotion)
Portage de services/ffmpeg.ts, services/reels/*, ttsSync.ts et
routes/reels.ts : même file persistante reel_jobs (FOR UPDATE SKIP
LOCKED, tentatives, battement de cœur, synchronisation des colonnes
generation_* des posts), mêmes validations (messages zod reproduits,
paramètres stockés en JSON camelCase compatibles avec les jobs de
l'ancien serveur), mêmes réponses JSON.

Le rendu Remotion passe par un nouveau service Node (remotion-service/) :
POST /renders, suivi de la progression, téléchargement du MP4 en flux,
mêmes réglages d'encodage. Sans REMOTION_RENDERER_URL, repli sur le
rendu FFmpeg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:36:20 +00:00
Claude 9d14b2a5f0 Serveur Rust : formatage et corrections clippy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:24:14 +00:00
Claude 73e4a4c822 Test de compatibilité du déchiffrement avec l'ancien serveur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:20:59 +00:00
Claude 25703abb7d Image Docker, CI et docs pour le serveur Rust
Image en trois étapes (frontend Vite, binaire Rust, Debian slim avec
FFmpeg), healthcheck /healthz, dépendances npm propres à l'ancien
serveur retirées, CI Rust (fmt, clippy, tests) et documentation de la
mise à jour au prochain démarrage de la stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:20:38 +00:00
Claude f9828d4887 Serveur Rust : socle (config, base, sessions, chiffrement, migrations)
Squelette du nouveau serveur axum/sqlx : accès aux données porté de
storage.ts, sessions compatibles connect-pg-simple, chiffrement des
jetons compatible, migrations idempotentes, en-têtes de sécurité,
limitation de débit et service des fichiers statiques.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
2026-10-12 17:14:58 +00:00
LogiFlow 51f4406173 Merge pull request #126 from R0m1k3/claude/busy-meitner-b7azzi
Afficher les publications déjà prévues par jour et par magasin
2026-10-12 12:21:41 +02:00
Claude 53ab6f489a Afficher les publications déjà prévues par jour et par magasin
Le sélecteur de date des Reels ne montrait rien de ce qui était déjà
programmé, et celui des posts ne marquait qu'un jour « occupé » sans
dire pour quel magasin ni à quelle heure.

- GET /api/scheduled-posts/agenda : publications planifiées et Reels
  encore en montage (qui n'ont pas de scheduled_posts avant la fin du
  rendu), limités aux magasins accessibles.
- Calendrier : pastille par jour, en rouge si un magasin coché a déjà
  une publication, en gris s'il s'agit d'autres magasins.
- Heures : badge sur les heures déjà prises sur les magasins cochés.
- Panneau « Déjà prévu ce jour-là » groupé par magasin, avec une
  alerte non bloquante en cas de doublon.
- Branché sur Nouveau post, Reel et Programmer un Reel.
- Icône Reel dans la vue calendrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMU8FG2v2EE54YWHhqehPw
2026-10-12 10:19:51 +00:00
Claude 62475eb43b Autorise git fetch dans les permissions locales Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
2026-10-06 14:37:48 +00:00
Claude 79114a741b Refonte UI : finitions (en-tête mobile, menu thème, libellés FR) et charte graphique
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
2026-10-06 13:01:51 +00:00
Claude 7a7188e508 Refonte UI : médiathèque, calendrier, aperçu et statistiques sur les nouveaux tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
2026-10-06 12:56:22 +00:00
Claude bf23ecd226 Refonte UI : Reel, programmation de Reel et éditeur d'images
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
2026-10-06 12:54:10 +00:00
Claude cdbc4aee39 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
2026-10-06 12:50:46 +00:00
Claude 7c18dc1e16 Refonte UI : SQL, musiques, historique, comptes connectés, utilisateurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ADkrVcVFTT14hbB148HYS
2026-10-06 12:47:49 +00:00
Claude 155c3385c1 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
2026-10-06 12:44:11 +00:00
LogiFlow 01315bf281 Merge pull request #124 from R0m1k3/claude/practical-newton-aal4xl
Reels : option « Publier aussi en story » sur Facebook
2026-10-05 09:03:19 +02:00
Claude 086ef956e2 Reels : option « Publier aussi en story » sur Facebook
Une case sur les écrans de création (bureau, mobile) et de programmation
d'un Reel double la publication d'une story vidéo sur les pages Facebook
sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas.

- Publication immédiate : la story part juste après le Reel, à partir de la
  vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer).
  Un échec de la story est noté dans l'historique sans faire échouer le Reel.
- Publication programmée : une entrée « story » est planifiée à côté du
  Reel ; le planificateur la publie comme les autres stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
2026-10-05 06:50:33 +00:00
LogiFlow 7806f8bdc8 Merge pull request #122 from R0m1k3/claude/dreamy-feynman-jxb0g3
Retire le générateur vidéo et les badges « New », Reels en échec supprimables
2026-09-29 15:52:21 +02:00
Claude da9a74dbe6 Retire le générateur vidéo, les badges « New » et permet d'effacer les Reels en échec
- Suppression de la page « Générateur Vidéo » (/remotion) : routes
  /api/remotion, pipeline de rendu d'images, composition ImageVideo et
  aperçu associé. Le générateur de Reels est conservé.
- Plus de badge « New » à côté des menus.
- Tableau de bord : bouton pour supprimer un Reel en échec et sa
  notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
2026-09-29 13:32:17 +00:00
LogiFlow e89dd97881 Merge pull request #121 from R0m1k3/claude/dreamy-feynman-jxb0g3
Reels : programmer la publication d'une vidéo déjà montée
2026-09-29 15:20:21 +02:00
Claude 188706f4dc Reels : programmer la publication d'une vidéo déjà montée
Nouvelle page « Programmer un Reel » (/reel/schedule) : envoi ou choix
d'une vidéo, description, pages Facebook/TikTok, date et heure. Le Reel
est publié tel quel par le planificateur, sans passer par le générateur.

- POST /api/posts valide les Reels (une vidéo, pages Facebook/TikTok)
- vignette extraite à l'envoi des vidéos pour l'historique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
2026-09-29 13:16:06 +00:00
LogiFlow 2c732c11b9 Merge pull request #120 from R0m1k3/claude/trusting-hawking-47qpzb
Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
2026-09-29 14:02:00 +02:00
Claude 338546662f Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
Vu en production avec Qwen sur GPU : la plupart des phrases basculaient sur
Gemini puis Edge (voix différente d'un sous-titre à l'autre).
- Vérification de lecture : les nombres en lettres (« trente », « vingt-cinq
  pour cent ») et leur signe (« plus », « moins ») sont comparés comme les
  chiffres écrits par Whisper, au lieu de passer pour des mots sautés.
- Prises emballées (14 s pour 34 caractères) écartées d'office ; côté
  qwen-tts, max_new_tokens borne la durée à « caractères / 6 + 3 » s.
- Le moteur choisi est gardé tant qu'il lit au moins 70 % du texte
  (avec avertissement), au lieu de 85 % ; 3 tentatives Qwen.
- Gemini : délai d'attente 45 s au lieu de 120 s, erreurs journalisées en repr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 12:01:42 +00:00
LogiFlow 90a9b6be4d Merge pull request #119 from R0m1k3/claude/trusting-hawking-47qpzb
qwen-tts : chargement des modèles hors ligne
2026-09-29 13:31:25 +02:00
Claude 90f936cf4f qwen-tts : chargement des modèles hors ligne
transformers interroge l'API Hugging Face au chargement quand le modèle est
désigné par son nom de dépôt (détection des tokenizers Mistral), ce qui
plantait le démarrage avec HF_HUB_OFFLINE=1 (conteneur en boucle sur Unraid).
Le modèle est désormais chargé depuis son dossier local dans le cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 11:31:06 +00:00
LogiFlow 5f271e0831 Merge pull request #118 from R0m1k3/claude/trusting-hawking-47qpzb
Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres
2026-09-29 12:09:10 +02:00
Claude fdace9d8d5 Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres
- qwen-tts : GPU NVIDIA détecté automatiquement (bfloat16), image CUDA 12.8
  (RTX 50xx comprises) via TORCH_VARIANT=cu128, docker-compose.gpu.yml
  dédié (clé obligatoire, choix de la carte) et guide d'installation Unraid.
- Paramètres → « Qwen TTS (voix locale) » : adresse + clé du service, test
  de connexion (modèle et carte graphique affichés), stockés en base
  (app_config.qwen_tts_url / qwen_tts_api_key) et transmis à ffmpeg-api à
  chaque voix ; QWEN_TTS_URL reste un défaut.
- Le service qwen-tts CPU du docker-compose principal devient optionnel
  (profil qwen-local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 09:57:32 +00:00
LogiFlow e312733d43 Merge pull request #117 from R0m1k3/claude/trusting-hawking-47qpzb
Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
2026-09-29 11:43:30 +02:00
Claude 44daa88a49 Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
SRT : la voix n'est plus jamais accélérée (atempo sans plafond, jusqu'à 2×
et plus, rendait la voix incompréhensible). Un morceau plus long que son
sous-titre déborde et décale les suivants ; les sous-titres suivent la voix,
la vidéo s'allonge et un avertissement signale le décalage.

Rythme : style par défaut « neutre », consignes Gemini sans « rythme
entraînant », débit Edge des styles dynamique/promo ramené à +0 %/+3 %.

Nouveau moteur « qwen » : service qwen-tts (Qwen3-TTS sur CPU, gratuit,
français, ton piloté par consigne, clonage de voix depuis qwen-tts/voices).
Même vérification Whisper que Gemini, généralisée ; repli qwen → gemini →
edge, et une lecture sous 85 % du texte est écartée (mots sautés/faux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 09:39:47 +00:00
Claude 4b8fbe6d8e fix(voix SRT): prix et unités reconnus, coupes dans les vraies pauses
- Contrôle de lecture : les nombres et l'unité qui les suit ne sont plus
  comparés lettre à lettre. Whisper écrit « 11 € 99 » ou « 40 cm » là où le
  texte dit « 11,99 € » ou « 40 centimètres » : une lecture correcte passait
  pour des mots sautés (88–91 %) et relançait jusqu'à 3 générations.
- Découpe de la lecture SRT : la coupe entre deux sous-titres se fait dans
  la pause mesurée la plus proche de la borne estimée (fins de phrases
  préférées aux virgules), et non plus à la borne Whisper elle-même, fausse
  quand un prix non reconnu termine la phrase : la coupe tombait alors au
  milieu d'une phrase.
- Whisper reçoit 0,5 s de silence avant la voix : il laissait passer le
  premier mot (« Alerte ») d'une voix qui démarre aussitôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3ywVwJUjjUXuvM6PwSzSi
2026-09-28 16:44:41 +00:00
Claude 7c38ec459f fix(voix): refuser les lectures Gemini qui sautent des mots
Le contrôle lettre à lettre acceptait une lecture dès 85 %, alors qu'une
lecture complète atteint ~97 % (petites erreurs de Whisper) : une lecture à
86 % (mots sautés) passait sans nouvelle génération. Seuil porté à 92 %.

Les mots non entendus sont nommés dans les journaux et l'avertissement,
pour savoir ce qui manquait. Le modèle Whisper, déjà dans l'image, est
chargé sans interroger Hugging Face (HF_HUB_OFFLINE).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:25:43 +00:00
Claude d4c544e467 fix(voix): plus de fins de phrases coupées ni de bruits parasites
Fins de phrases coupées :
- Le fondu final ne commence plus avant la fin de la voix (il rognait la
  dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au
  besoin. Même règle côté Python, aperçu et rendu Remotion
- SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée
  sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au
  niveau de la voix qui respecte les fins douces ; 300 ms gardées après le
  dernier son

Bruits parasites :
- Normalisation de la voix en deux passes, en mode linéaire : le mode
  dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB
  avant, -75 dB après dans les pauses)
- Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus
  de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20)
- Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le
  dernier mot sont retirés

Vérification de lecture comparée lettre à lettre : les petites erreurs de
Whisper ne déclenchent plus de régénérations inutiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:21:18 +00:00
Claude 44899055b2 fix(voix): intonation stable, lecture vérifiée et SRT sans mots coupés
Intonation :
- Gemini reçoit une température basse et une graine fixe (réglables) : la
  lecture ne change plus de ton d'une génération à l'autre
- SRT : tout le texte est lu d'une seule traite puis découpé phrase par
  phrase, au lieu d'une génération (et d'une intonation) par sous-titre ;
  un seul traitement de niveau, plus de sauts de volume entre les phrases

Mots coupés :
- La voix est découpée dans ses vrais silences (silencedetect), plus aux
  bornes des mots estimées par Whisper qui rognaient les syllabes ; fondus
  de 10 ms aux coupes
- Whisper n'est plus amorcé avec le texte attendu : il hallucinait des
  répétitions qui masquaient les fins tronquées

Fiabilité :
- Chaque voix Gemini est réécoutée : mots sautés, fin tronquée ou consigne
  lue à voix haute déclenchent une nouvelle génération (3 au plus), puis
  repli sur Edge avec avertissement
- Consigne de lecture intégrale ajoutée au prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:04:08 +00:00
Claude f3113a0f5a fix(reels): garder le son d'origine de la vidéo quand aucune musique n'est choisie
Sans musique (bouton « Passer »), la bande son de la vidéo était supprimée
dès que la voix était activée. Elle sert désormais de fond sonore : conservée
telle quelle, baissée automatiquement sous la voix, puis normalisée avec le
reste. Une musique choisie la remplace toujours.

Même comportement dans l'aperçu, le rendu Remotion et le rendu FFmpeg de
secours. Le bouton indique « Passer (garder le son de la vidéo) ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 08:57:38 +00:00
Claude 1d47763b29 feat(reels): interrupteur « Afficher le logo sur la vidéo »
Le petit logo affiché pendant la vidéo devient optionnel (activé par
défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que
dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre
interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de
secours (show_watermark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 06:45:38 +00:00
Claude f5e2461da1 feat(reels): import d'un fichier SRT lu par la voix à son minutage
- Nouveau Reel (bureau et mobile) : bouton « Importer un fichier SRT ».
  Tant qu'un SRT est chargé, le texte libre et l'assistant IA sont
  désactivés ; le choix de la voix (moteur, voix, ton) reste disponible.
- Service Python : chaque sous-titre est lu séparément, ses silences
  retirés, puis posé à son instant de début. Une lecture trop longue est
  accélérée (atempo, sans changer la hauteur) pour tenir dans la durée du
  sous-titre, avec un avertissement au-delà de ×1,35. Après un repli sur
  Edge, tous les sous-titres gardent la même voix.
- Sans voix, les mots de chaque sous-titre s'affichent sur son intervalle.
- Aperçu : sous-titres calés sur le minutage du fichier.
- Tests : lecture SRT (TS), fenêtres, accélération et mixage (Python).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVqEw4Xycgf8BtwmSftE2M
2026-09-27 08:19:44 +00:00
Claude 9b66a6dc5a feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 14:37:42 +00:00
Claude 3c6a66bf64 feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
Service ffmpeg-api réécrit (ffmpeg-service/app/) :
- Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu
- Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON
- Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible
- 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme),
  clé en en-tête, modèle configurable avec repli
- Edge TTS 7 (minutage des mots restauré), secours en voix françaises
- Voix traitée : filtre, compression, niveau constant ; musique bouclée et
  baissée automatiquement sous la voix ; mix final à -14 LUFS
- Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine,
  à la place de ffsubsync), style Montserrat, placés hors des boutons Reels
- Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone
  converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse
- Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et
  modèle Whisper intégrés à l'image ; tests pytest et ruff

Application :
- Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute
- Reel images : minutage réel des mots, interrupteur voix respecté
- sync-info ne génère plus de voix à chaque frappe (estimation locale)
- Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée
- Log « [ReelQueue] Worker démarré » pour vérifier la version déployée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 12:31:27 +00:00
Claude d24ab0d4b7 feat(reels): file d'attente persistante des rendus (reel_jobs)
L'ancienne file comptait les posts « processing » : un redémarrage pendant
un rendu la bloquait définitivement. Les rendus passent désormais par une
table reel_jobs, réservée avec FOR UPDATE SKIP LOCKED, avec heartbeat et
reprise des jobs interrompus au démarrage (2 tentatives maximum).

- Pipeline vidéo et pipeline images (Remotion) extraits des routes vers
  server/services/reels/, les routes ne font que valider (zod) et mettre en file
- Rendus d'images suivis en base au lieu d'une Map en mémoire
- storeName conservé pour les jobs mis en attente
- Publication Facebook en binaire : l'URL relative /uploads/... transmise
  auparavant n'était pas téléchargeable par Facebook
- Job en échec si toutes les pages échouent ou si la voix demandée manque
  (le service Python renseigne enfin tts_error)
- La voix choisie sur la page images est réellement utilisée
- sync-info mesure la voix Gemini avec sa clé
- Timeouts sur les appels au service FFmpeg, vignettes via execFile,
  /debug-ffmpeg protégé par la clé API, plus de voix anglaise en secours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 09:31:31 +00:00
Claude fcbc6b706f fix(docker): construire les images au lieu de tenter de les télécharger
Portainer échouait en « Pull and redeploy » : `pull access denied for
sociaflow-app, repository does not exist`. Les services `app` et
`ffmpeg-api` n'ayant que `build:`, Compose leur dérivait un nom d'image
depuis le nom de la stack et Docker allait la chercher sur Docker Hub,
où elle n'existe évidemment pas.

- `pull_policy: build` : l'image est reconstruite, jamais téléchargée
- `image:` explicite, pour que le nom ne dépende plus du nom donné à la
  stack dans Portainer
- DOCKER.md : procédure Portainer et diagnostic de cette erreur

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:08:48 +00:00
Claude 3af7f9418b fix(oauth): déduire l'URI de redirection du domaine public réel
Sans APP_URL, Facebook et TikTok recevaient
http://localhost:5555/api/{facebook,tiktok}/callback : une URI que les
fournisseurs refusent, donc aucune page connectable derrière un reverse
proxy. Le repli localhost n'est plus utilisé que hors requête HTTP ; en
service, la base publique est reconstruite depuis X-Forwarded-Proto /
X-Forwarded-Host (trust proxy est déjà activé).

- `resolvePublicBaseUrl(req)` centralise la résolution (APP_URL prioritaire)
- l'URI de redirection est déduite de la requête au démarrage du flux, à
  l'échange du code et dans l'affichage des paramètres : les trois restent
  identiques, comme l'exige OAuth
- docker-compose ne force plus APP_URL à localhost, qui rendait l'auto
  détection inopérante
- cookie de session en `secure: 'auto'` à défaut d'APP_URL, pour ne pas
  émettre un cookie non sécurisé derrière un proxy HTTPS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:01:28 +00:00
Claude 94e961d0f1 perf(api): alléger les requêtes du calendrier, des stats et de la médiathèque
Trois défauts se cumulaient : des réponses bien plus larges que nécessaire,
aucun index en base, et des boucles de requêtes là où une seule suffisait.

Réponses
- /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons
  d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les
  colonnes réellement affichées sont désormais sélectionnées.
- Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après
  avoir chargé tout l'historique — que le client ne bornait jamais. Le
  calendrier demande maintenant les six semaines affichées, le sélecteur de
  date les créneaux à venir, le bloc « publications récentes » ses 90 jours.
- /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des
  écrans doivent retrouver un média par son identifiant). La médiathèque
  pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et
  l'écran de création ne demande que les douze médias qu'il propose. Le total
  voyage dans l'en-tête X-Total-Count pour garder le compteur juste.

Index
- server/migrate.ts est le seul script exécuté au démarrage et n'en créait
  aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant
  monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier,
  l'historique et le scheduler parcouraient les tables entières. S'y ajoutent
  deux index partiels pour les boucles de fond : publications en attente et
  suivi asynchrone TikTok.

Requêtes
- /api/stats lisait quatre tables entières pour en compter les lignes : c'est
  désormais quatre COUNT filtrés.
- Les vues d'administration listaient les utilisateurs puis interrogeaient les
  pages de chacun ; une seule requête les remplace.
- getPostWithMedia et l'API externe chargeaient les médias un par un.
- /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder
  que ceux en cours de génération, toutes les trois secondes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:39:16 +00:00
Claude 25bf0981bd perf(client): rafraîchir les données sans recharger la page
`staleTime: Infinity` combiné à `refetchOnWindowFocus: false` empêchait toute
revalidation : une donnée chargée à la première visite restait affichée pour
toute la session. Or le serveur agit de son côté — le scheduler publie chaque
minute, les jetons se renouvellent en tâche de fond — sans que le navigateur en
soit averti. D'où la nécessité de recharger la page pour voir quoi que ce soit.

Les compteurs du tableau de bord étaient le cas le plus visible : /api/stats et
/api/posts n'étaient invalidés par aucune mutation de l'application, et ne
pouvaient donc plus bouger du tout.

- Péremption courte (30 s) et rafraîchissement au retour sur l'onglet ou sur
  reconnexion, en remplacement du cache éternel.
- /api/stats et /api/posts, qui dérivent de tout le reste, sont invalidés depuis
  le MutationCache après chaque mutation réussie : aucune mutation ne peut
  raisonnablement se souvenir de le faire, et aucune ne le faisait. Les
  mutations d'authentification en sont exclues, la session étant déjà détruite
  quand la déconnexion aboutit.
- Les trois sondages à 3 s (historique desktop et mobile, reels en cours)
  devenaient inutiles hors traitement en cours. Ils ne s'emballent plus que
  lorsqu'il y a effectivement quelque chose à suivre — une publication dont
  l'heure est passée, une génération en route — et retombent à 30 s sinon. La
  page Historique rechargeait tout l'historique 20 fois par minute.
- La liste des modèles OpenRouter, servie par un appel sortant, garde une
  péremption d'une heure pour ne pas être redemandée à chaque retour d'onglet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:22:21 +00:00
Claude d7f002a64f feat(facebook): renouvellement automatique des jetons de page
Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au
lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si
Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication
en échec : le contrôle quotidien existant se contentait de marquer la page
« expired ».

On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation
OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis
dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est
conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de
régénérer un jeton révoqué sans intervention humaine, et de se prolonger
lui-même avant échéance.

- facebook_config : App ID / App Secret de l'application, saisis dans les
  paramètres (chiffrés en base) ou par variables d'environnement.
- /api/facebook : configuration, connexion OAuth, callback qui crée ou met à
  jour les pages, contrôle d'une page à la demande, contrôle global.
- TokenManager : /debug_token pour connaître l'état réel du jeton, puis
  renouvellement automatique 15 jours avant l'échéance ou dès révocation ;
  repli sur le contrôle historique tant que l'application n'est pas configurée
  ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une
  panne réseau. Le cron passe à deux fois par jour.
- Les comptes Instagram professionnels rattachés à une page reçoivent le même
  traitement, puisqu'ils publient avec le jeton de cette page.
- L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur
  à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à
  reconnecter.
- /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur
  de renouvellement automatique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:11:43 +00:00
LogiFlow 55e502aa8d Merge pull request #110 from R0m1k3/claude/tiktok-store-account-management-m558za
Claude/tiktok store account management m558za
2026-08-12 10:02:11 +02:00
Claude ac8b494f24 fix: vignettes vidéo persistantes et remise à zéro des erreurs de types
Deux corrections demandées ensemble.

1. Vignettes vidéo

Le planificateur supprime la vidéo locale une fois publiée, alors que la
ligne `media` survit pour l'historique. L'interface tentait donc de lire
un fichier disparu pour en extraire une image : d'où les avertissements
« Video thumbnail generation failed » et les tuiles vides.

Une vignette JPEG est désormais extraite à la création du reel, dans les
deux flux de génération, et stockée à part : elle reste disponible quand
la vidéo ne l'est plus. La purge quotidienne la nettoie en même temps que
la ligne qu'elle illustre. L'extraction est tolérante à l'échec — une
vignette manquante dégrade l'affichage mais ne fait jamais échouer une
publication — et le composant retombe sur son icône de remplacement pour
les médias créés avant ce changement. La fonction d'extraction, jusque-là
privée à la route Remotion, devient un service partagé.

2. Erreurs de compilation préexistantes (27 → 0)

- remotion-video.tsx (bureau et mobile) : les composants Select étaient
  utilisés sans être importés, ce qui faisait planter le sélecteur de voix
  à l'exécution.
- external.ts : fileSize et aiGenerated étaient transmis dans un type que
  le schéma ne déclare pas, et ne fonctionnaient que par conversion
  implicite de PostgreSQL.
- reels.ts : l'interface MusicTrack, référencée mais jamais déclarée dans
  ce module.
- remotion.ts : chromiumOptions.args retiré. Remotion ne lit jamais cette
  clé, les drapeaux Chrome qu'elle contenait n'atteignaient donc pas le
  navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:00:48 +00:00
Claude c69fe4e462 fix(ia): afficher la cause réelle d'un échec de génération de texte
La route /api/ai/generate connaissait la cause exacte de l'échec
(configuration absente, clé invalide, crédits épuisés, modèle inconnu)
mais la remplaçait par un « Failed to generate text » en 500, et
l'interface affichait de son côté un message figé. Sans accès aux logs
du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi
corriger.

Le motif renvoyé par OpenRouter est désormais extrait, tronqué et
propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un
problème de configuration (400) d'un refus du service tiers (502).
La clé API n'apparaît à aucun moment dans ces messages.

Même traitement pour /api/reels/generate-text, qui souffrait du même
masquage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 07:49:23 +00:00
Claude 0570088722 feat(tiktok): servir le fichier de vérification du domaine
TikTok exige de prouver la propriété du domaine ("URL properties") avant
d'autoriser la Content Posting API, en servant un fichier à la racine du
site.

Le dossier public-root/ est exposé publiquement à la racine du domaine et
monté avant le catch-all du frontend. Y déposer un fichier suffit : pas de
modification de code ni de reconstruction du client, ce qui couvre aussi
les prochaines vérifications (autre domaine, Google, Meta).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:27:26 +00:00
Claude 3e00a3ca67 feat(legal): préremplir les mentions légales de la société exploitante
Renseigne l'identité de FROUARD DISTRIBUTION (enseigne LA FOIR'FOUILLE)
comme valeur par défaut des pages /terms et /privacy : forme juridique,
capital, siège et numéro RCS. Les relecteurs TikTok ouvrent ces pages
pendant l'audit et refusent les mentions incomplètes.

Chaque champ reste surchargeable par variable d'environnement si l'outil
venait à être exploité par une autre société du groupe.

L'adresse de contact reste à fournir via LEGAL_CONTACT_EMAIL : elle ne
figure pas au registre, et le RGPD impose un point de contact pour
l'exercice des droits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:19:16 +00:00
Claude d0f63c68e4 feat(legal): servir les pages /terms et /privacy exigées par TikTok
L'audit de l'application développeur TikTok impose une URL publique pour
les conditions d'utilisation et pour la politique de confidentialité, et
les relecteurs les ouvrent réellement.

Les pages sont rendues en HTML côté serveur, sans authentification, pour
rester lisibles par un robot qui n'exécute pas le JavaScript du client.
La politique décrit précisément ce que l'intégration fait : données reçues
de TikTok (open_id, nom d'affichage, avatar, jetons), finalité de
publication, chiffrement des jetons au repos, suppression à la
déconnexion et marche à suivre pour retirer l'autorisation.

Les mentions de l'exploitant se configurent via LEGAL_COMPANY_NAME,
LEGAL_COMPANY_ADDRESS et LEGAL_CONTACT_EMAIL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:14:13 +00:00
Claude b95cd23ebc feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo
sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule
publication.

Un compte TikTok vit dans social_pages comme une page Facebook : il hérite
donc des permissions par utilisateur, de la planification et du calendrier.

Trois particularités de l'API TikTok structurent l'implémentation :
- pas de jeton saisi à la main : chaque compte passe par OAuth, et
  l'access token (24h) est renouvelé via le refresh token (1 an) avant
  chaque publication ;
- la publication est asynchrone : l'upload rend un publish_id, et un
  poller récupère l'identifiant définitif du post ;
- creator_info doit être interrogé avant chaque envoi pour ne demander
  qu'un niveau de confidentialité réellement autorisé sur le compte.

Serveur :
- service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks,
  suivi de statut) et routes de connexion/configuration
- schéma : platform 'tiktok', refresh token et scopes sur social_pages,
  publish_id/publish_status sur scheduled_posts, table tiktok_config
- routage par plateforme dans les deux flux de reels et le planificateur
- cron de suivi des publications, token manager et analytics adaptés

Client :
- connexion et reconnexion des comptes TikTok depuis « Pages gérées »
- sélection combinée pages Facebook / comptes TikTok à la publication
- configuration de l'application TikTok dans les paramètres (admin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 14:29:38 +00:00
295 changed files with 39767 additions and 29764 deletions

No files matched your search

+5 -1
View File
@@ -3,7 +3,11 @@
"allow": [ "allow": [
"WebFetch(domain:pixabay.com)", "WebFetch(domain:pixabay.com)",
"Bash(find /c/GIT/Socialflow/migrations -name \"*.sql\" -exec wc -l {} +)", "Bash(find /c/GIT/Socialflow/migrations -name \"*.sql\" -exec wc -l {} +)",
"Bash(xargs ls:*)" "Bash(xargs ls:*)",
"Bash(git fetch *)",
"Bash(git push *)",
"Bash(git add *)",
"Bash(git commit -qm 'Autorise git fetch dans les permissions locales Claude Code *)"
] ]
} }
} }
+6
View File
@@ -1,5 +1,6 @@
# Dépendances # Dépendances
node_modules node_modules
**/node_modules
npm-debug.log npm-debug.log
# Fichiers de build # Fichiers de build
@@ -38,3 +39,8 @@ logs
# Uploads (seront montés comme volume) # Uploads (seront montés comme volume)
uploads/* uploads/*
# Compilation Rust
server/target
**/target
.claude
+113 -1
View File
@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://socialflow:changeme@postgres:4523/socialflow
# Configuration de l'application # Configuration de l'application
PORT=5555 PORT=5555
NODE_ENV=production NODE_ENV=production
# URL publique de l'application, telle que vue par le navigateur.
# En production, mettez le vrai domaine (ex. https://socialflow.exemple.fr) :
# les URI de redirection OAuth (Facebook/TikTok) en découlent.
# Laissée vide derrière un reverse proxy, elle est déduite des en-têtes
# X-Forwarded-Proto / X-Forwarded-Host.
APP_URL=http://localhost:5555 APP_URL=http://localhost:5555
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
@@ -30,17 +35,124 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://media.example.com MINIO_PUBLIC_URL=https://media.example.com
# Optionnel: Configuration Facebook API # ==========================================
# FACEBOOK (connexion des pages et renouvellement des jetons)
# ==========================================
# Identifiants de votre application sur https://developers.facebook.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook),
# ce qui est prioritaire sur ces variables.
# Sans eux, les jetons de page doivent être collés et remplacés à la main.
# FACEBOOK_APP_ID= # FACEBOOK_APP_ID=
# FACEBOOK_APP_SECRET= # FACEBOOK_APP_SECRET=
#
# URI de redirection OAuth à déclarer chez Facebook.
# Par défaut : ${APP_URL}/api/facebook/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# FACEBOOK_REDIRECT_URI=
#
# Permissions demandées à l'autorisation. Défaut : pages_show_list,
# pages_manage_posts, pages_read_engagement, pages_manage_engagement,
# read_insights, business_management, instagram_basic, instagram_content_publish
# FACEBOOK_SCOPES=
#
# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0).
# FACEBOOK_GRAPH_VERSION=
# Optionnel: Configuration Instagram API # Optionnel: Configuration Instagram API
# INSTAGRAM_APP_ID= # INSTAGRAM_APP_ID=
# INSTAGRAM_APP_SECRET= # INSTAGRAM_APP_SECRET=
# ==========================================
# TIKTOK (publication des reels)
# ==========================================
# Identifiants de votre application sur https://developers.tiktok.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok),
# ce qui est prioritaire sur ces variables.
# TIKTOK_CLIENT_KEY=
# TIKTOK_CLIENT_SECRET=
#
# URL de redirection OAuth à déclarer chez TikTok.
# Par défaut : ${APP_URL}/api/tiktok/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# TIKTOK_REDIRECT_URI=
#
# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish).
# "video.publish" nécessite que l'application ait passé l'audit TikTok ;
# sans audit, les vidéos publiées restent privées.
# TIKTOK_SCOPES=
# ==========================================
# MENTIONS LÉGALES
# ==========================================
# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige
# les URLs pour l'audit de l'application développeur.
#
# L'identité de la société exploitante est déjà la valeur par défaut : seule
# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD
# imposant un point de contact pour l'exercice des droits.
LEGAL_CONTACT_EMAIL=
# Surcharges facultatives (utiles si l'outil est exploité par une autre société)
# LEGAL_COMPANY_NAME=
# LEGAL_TRADE_NAME=
# LEGAL_COMPANY_LEGAL_FORM=
# LEGAL_COMPANY_RCS=
# LEGAL_COMPANY_ADDRESS=
# ========================================== # ==========================================
# API EXTERNE # API EXTERNE
# ========================================== # ==========================================
# Clé d'accès pour l'API externe /api/v1/* # Clé d'accès pour l'API externe /api/v1/*
# Générer avec: openssl rand -hex 32 # Générer avec: openssl rand -hex 32
EXTERNAL_API_KEY=your-secure-random-key-here EXTERNAL_API_KEY=your-secure-random-key-here
# ==========================================
# REELS : VOIX ET RENDU VIDÉO (service ffmpeg-api)
# ==========================================
# Clé partagée entre l'application et le service FFmpeg (CHANGEZ CETTE VALEUR).
FFMPEG_API_KEY=change-me-ffmpeg-key
#
# Clé Google AI Studio pour les voix Gemini (sinon : voix Edge, gratuite).
# Peut aussi être saisie dans l'application, ce qui est prioritaire.
GEMINI_API_KEY=
#
# Modèle Gemini TTS (défaut : gemini-2.5-flash-preview-tts ; repli automatique
# sur celui-ci si le modèle choisi n'existe pas).
# GEMINI_TTS_MODEL=
#
# Stabilité de la voix Gemini : température basse et graine fixe donnent la
# même intonation d'une génération à l'autre ; chaque lecture est réécoutée et
# régénérée (jusqu'à GEMINI_TTS_ATTEMPTS fois) si des mots manquent.
# GEMINI_TTS_TEMPERATURE=0.3
# GEMINI_TTS_SEED=20260928
# GEMINI_TTS_ATTEMPTS=3
#
# Modèle Whisper utilisé pour caler les sous-titres mot à mot sur la voix
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
# demande de reconstruire ffmpeg-api.
# WHISPER_MODEL=base
#
# Voix Qwen3-TTS : gratuite, française, sans quota. Le plus simple : l'installer
# sur un serveur avec carte graphique NVIDIA (qwen-tts/docker-compose.gpu.yml,
# ex. Unraid) puis régler l'adresse dans Paramètres → Qwen TTS.
# Sinon, service CPU sur ce serveur (lent : 5 à 7 fois la durée de la voix) :
# docker compose --profile qwen-local up -d avec QWEN_TTS_URL=http://qwen-tts:8001
# QWEN_TTS_URL=
# Modèle choisi à la construction de l'image :
# - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice (défaut) : ton et émotion pilotés ;
# - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif.
# QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
# Clonage des voix déposées dans qwen-tts/voices (vide pour désactiver).
# QWEN_TTS_CLONE_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-Base
# QWEN_TTS_THREADS=0
# QWEN_TTS_MEMORY=10g
#
# Moteur de rendu des Reels vidéo :
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
# - ffmpeg : plus rapide (~10 s), sous-titres karaoké classiques.
# REEL_RENDERER=remotion
#
# Onglets Chromium utilisés en parallèle par Remotion (défaut : moitié des cœurs).
# Réduisez-le si le serveur manque de mémoire.
# RENDER_CONCURRENCY=
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
frontend:
name: Frontend (TypeScript)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci --ignore-scripts
- run: npm run check
- run: npm test
- run: npm run build
server:
name: Serveur (Rust)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: server
- run: cargo fmt --check
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked
ffmpeg-service:
name: Service vidéo (Python)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ffmpeg-service
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: ffmpeg-service/requirements*.txt
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: ruff format --check .
- run: pytest -q
+3
View File
@@ -33,3 +33,6 @@ npm-debug.log*
# OS # OS
Thumbs.db Thumbs.db
# Rust
server/target
.claude/worktrees
+86 -14
View File
@@ -4,7 +4,8 @@
L'application utilise Docker Compose pour orchestrer : L'application utilise Docker Compose pour orchestrer :
- **PostgreSQL 16** : Base de données sur réseau interne - **PostgreSQL 16** : Base de données sur réseau interne
- **Social Flow App** : Application Node.js exposée sur le port **4523** - **Social Flow App** : serveur Rust (API + frontend compilé), port interne **5555**
- **ffmpeg-api** / **remotion-renderer** : services de traitement vidéo des Reels
## 🚀 Démarrage rapide ## 🚀 Démarrage rapide
@@ -136,6 +137,62 @@ docker-compose down
docker-compose down -v docker-compose down -v
``` ```
## 🔗 URL publique et connexions OAuth (Facebook / TikTok)
Les boutons « Connecter une page » construisent une URI de redirection à partir
de l'URL publique de l'application. Renseignez donc `APP_URL` avec le domaine
réel :
```bash
APP_URL=https://socialflow.exemple.fr
```
Puis déclarez l'URI correspondante chez le fournisseur :
- Facebook : *Connexion Facebook → Paramètres → URI de redirection OAuth valides*
→ `https://socialflow.exemple.fr/api/facebook/callback`
- TikTok : *Login Kit → Redirect URI* → `https://socialflow.exemple.fr/api/tiktok/callback`
Si `APP_URL` est absent, l'URL est déduite des en-têtes `X-Forwarded-Proto` et
`X-Forwarded-Host` envoyés par Nginx — vérifiez que le proxy les transmet
(voir la configuration Nginx ci-dessus). Un `APP_URL` réglé sur
`http://localhost:5555` en production produit une URI que Facebook refuse.
## 🧩 Déploiement via Portainer (stack Git)
La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
`build:`, leurs images n'existent sur aucun registre.
- **« Pull and redeploy » échoue** avec `pull access denied for <stack>-app,
repository does not exist` : Docker cherche sur Docker Hub une image qui est
construite localement. Le `pull_policy: build` du `docker-compose.yml` évite
cette tentative ; si votre version de Portainer déclenche quand même le pull,
décochez **« Re-pull image »** dans la mise à jour de la stack.
- Pour reconstruire après une mise à jour du dépôt : *Stack → Editor →
Update the stack*, en laissant Docker rebâtir les images (ou
`docker compose up -d --build` en ligne de commande).
- Le nom des images est figé (`socialflow-app:latest`,
`socialflow-ffmpeg-api:latest`) : il ne dépend donc plus du nom donné à la
stack dans Portainer.
## 🎙️ Service ffmpeg-api (voix et rendu des Reels)
- **Premier build plus long** : l'image embarque FFmpeg 7.0.2 (version
épinglée), les polices des sous-titres et le modèle Whisper qui cale les
sous-titres mot à mot sur la voix (~150 Mo avec `WHISPER_MODEL=base`).
- **Voix Gemini** : renseignez `GEMINI_API_KEY` (ou la clé dans l'application).
Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît
dans les logs.
- **Moteur de rendu** : par défaut, les Reels vidéo sont montés par Remotion
(sous-titres animés, identiques à l'aperçu de l'application). En cas de
serveur trop lent, `REEL_RENDERER=ffmpeg` revient au rendu FFmpeg, plus
rapide. `RENDER_CONCURRENCY` limite le nombre d'onglets Chromium.
- **Vérifier la version déployée** : au démarrage, les logs de `socialflow-app`
affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond
`{"status":"ok","version":2}` sur `/health`.
- **Tests du service** : `pip install -r requirements-dev.txt`, puis `pytest`
et `ruff check .` dans `ffmpeg-service/`.
## 🔒 Sécurité en production ## 🔒 Sécurité en production
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env` 1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
@@ -147,7 +204,8 @@ docker-compose down -v
## 📊 Healthchecks ## 📊 Healthchecks
- **PostgreSQL** : Vérifie que la base est prête avant de démarrer l'app - **PostgreSQL** : Vérifie que la base est prête avant de démarrer l'app
- **Migrations** : Exécutées automatiquement au démarrage (`drizzle-kit push --force`) - **Migrations** : exécutées automatiquement par le serveur à chaque démarrage (idempotentes)
- **Application** : `GET /healthz` répond `ok` quand le serveur et la base sont joignables (healthcheck de l'image)
## 🐛 Dépannage ## 🐛 Dépannage
@@ -176,23 +234,37 @@ Vérifiez que `DATABASE_URL` dans `.env` correspond aux variables `PGUSER`, `PGP
## 📦 Volumes Docker ## 📦 Volumes Docker
- `postgres_data` : Données persistantes de PostgreSQL - `postgres_data` : données persistantes de PostgreSQL
- Mappages locaux : - `encryption_key` : clé de chiffrement des jetons (`/app/.encryption-key-data`)
- `./attached_assets` → `/app/attached_assets` (médias uploadés) - `audio_uploads`, `media_uploads`, `logos_uploads`, `stories_uploads` : fichiers téléversés (`/app/uploads/...`)
- `./migrations` → `/app/migrations` (migrations DB)
## 🔄 Mise à jour de l'application ## 🔄 Mise à jour de l'application
L'image `socialflow-app` est construite à partir des sources
(`pull_policy: build`) : le prochain démarrage de la stack après un `git pull`
applique la nouvelle version.
```bash ```bash
# 1. Pull les dernières modifications # 1. Récupérer les dernières modifications
git pull git pull
# 2. Reconstruire l'image # 2. Reconstruire et redémarrer (Portainer : « Update the stack »)
docker-compose build docker compose up -d --build
# 3. Redémarrer # 3. Vérifier les logs
docker-compose up -d docker compose logs -f app
# 4. Vérifier les logs
docker-compose logs -f app
``` ```
Au démarrage, le serveur complète le schéma de la base (tables, colonnes,
index) : aucune commande de migration n'est nécessaire. Les données, les
fichiers téléversés, la clé de chiffrement et les sessions ouvertes sont
conservés.
### Passage au serveur Rust
Depuis la version 2, le serveur est écrit en Rust (dossier `server/`) et
remplace l'ancien serveur Node/Express. La bascule ne demande rien de plus
qu'une reconstruction : mêmes variables d'environnement, même base, mêmes
volumes, et les utilisateurs déjà connectés le restent (cookie `connect.sid`
et table `session` repris tels quels). Le rendu Remotion des Reels tourne
désormais dans son propre conteneur (`remotion-renderer`).
+60 -76
View File
@@ -1,84 +1,68 @@
# Debian slim — glibc requis par le Chrome headless shell de Remotion (posix_fallocate64, etc.) # SocialFlow — image de l'application : serveur Rust + frontend React compilé.
FROM node:20-slim #
# Trois étapes : le frontend est compilé par Vite, le serveur par Cargo, et
# l'image finale ne contient que le binaire, les fichiers statiques et FFmpeg
# (vignettes vidéo, durée des pistes audio). Pas de Node à l'exécution.
# ---------------------------------------------------------------------------
# 1. Frontend (Vite)
# ---------------------------------------------------------------------------
FROM node:20-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
# Aucune dépendance native n'est utile au build du frontend
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY client ./client
COPY shared ./shared
COPY attached_assets ./attached_assets
COPY vite.config.ts tsconfig.json tailwind.config.ts postcss.config.js components.json ./
RUN npx vite build
# ---------------------------------------------------------------------------
# 2. Serveur (Rust)
# ---------------------------------------------------------------------------
FROM rust:1-slim-bookworm AS backend
WORKDIR /app/server
# Dépendances compilées dans une couche à part : elles ne sont recompilées que
# si Cargo.toml / Cargo.lock changent.
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/socialflow target/release/deps/socialflow-* target/release/.fingerprint/socialflow-*
COPY server/src ./src
RUN cargo build --release --locked
# ---------------------------------------------------------------------------
# 3. Image d'exécution
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
fonts-dejavu-core \
fontconfig \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Augmenter la mémoire AVANT npm install + build (évite OOM sur les grosses dépendances) COPY --from=backend /app/server/target/release/socialflow /usr/local/bin/socialflow
ENV NODE_OPTIONS="--max-old-space-size=4096" COPY --from=frontend /app/dist/public ./dist/public
COPY public-root ./public-root
COPY server/fonts ./server/fonts
# Dépendances système : build tools, canvas/sharp, fonts, ffmpeg + dépendances Chrome RUN mkdir -p uploads/media uploads/logos uploads/stories uploads/audio uploads/temp
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
libcairo2-dev libjpeg-dev libpango1.0-dev libgif-dev libpixman-1-dev \
libvips-dev pkg-config \
fonts-dejavu-core fontconfig \
curl tar bzip2 \
ffmpeg \
libcap2-bin \
ca-certificates \
# Dépendances Chrome headless
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxshmfence1 \
libxtst6 \
&& rm -rf /var/lib/apt/lists/*
# Copier les fichiers de configuration
COPY package*.json ./
# Installer les dépendances
RUN npm install --legacy-peer-deps --no-audit --no-fund
# Copier tout le code source
COPY . .
# Télécharger et installer la police DejaVu Sans
RUN mkdir -p server/fonts && \
cd server/fonts && \
curl -L -f --retry 3 --retry-delay 2 -o dejavu.tar.bz2 "https://sourceforge.net/projects/dejavu/files/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2/download" && \
tar -xjf dejavu.tar.bz2 && \
cp dejavu-fonts-ttf-2.37/ttf/DejaVuSans.ttf . && \
rm -rf dejavu.tar.bz2 dejavu-fonts-ttf-2.37 && \
echo "✓ Police DejaVu Sans installée"
# Build du frontend et backend
RUN npm run build
# Nettoyer les fichiers inutiles
RUN rm -rf client/node_modules \
client/.vite \
node_modules/.cache \
/tmp/remotion-*
# Pré-bundler la composition Remotion
RUN node scripts/prebundle-remotion.js || true
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
# Exposer le port
EXPOSE 5555
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=5555 ENV PORT=5555
EXPOSE 5555
CMD ["npm", "run", "start"] HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://localhost:${PORT}/healthz" > /dev/null || exit 1
CMD ["socialflow"]
+45 -34
View File
@@ -100,9 +100,9 @@ Architecture Production-Ready avec sécurité renforcée.
| Fonctionnalité | Détail | | Fonctionnalité | Détail |
| ------------------------------- | ---------------------------------------------------- | | ------------------------------- | ---------------------------------------------------- |
| **Authentification** | Passport.js + bcrypt, sessions PostgreSQL | | **Authentification** | bcrypt, sessions PostgreSQL (cookie signé) |
| **Rate Limiting** | 100 req/15min global, 5 tentatives login/15min | | **Rate Limiting** | 1000 req/15min global, 30 tentatives login/15min |
| **Headers de sécurité** | Helmet (CSP, HSTS, XSS protection) | | **Headers de sécurité** | CSP, HSTS, X-Frame-Options, nosniff… |
| **Chiffrement des tokens** | Tokens Facebook/Instagram chiffrés (AES-256) | | **Chiffrement des tokens** | Tokens Facebook/Instagram chiffrés (AES-256) |
| **Gestion multi-utilisateurs** | Rôles Admin / User, permissions par page | | **Gestion multi-utilisateurs** | Rôles Admin / User, permissions par page |
| **Console SQL** | Requêtes directes en base (admin uniquement) | | **Console SQL** | Requêtes directes en base (admin uniquement) |
@@ -117,43 +117,53 @@ Architecture Production-Ready avec sécurité renforcée.
socialflow/ socialflow/
├── client/ # Frontend React (Vite + TailwindCSS) ├── client/ # Frontend React (Vite + TailwindCSS)
│ └── src/ │ └── src/
│ ├── pages/ # 17 pages desktop + 13 pages mobile │ ├── pages/ # Pages desktop + mobile
│ ├── components/ # 47 composants UI (Radix/shadcn) │ ├── components/ # Composants UI (Radix/shadcn)
│ └── hooks/ # Hooks React personnalisés │ └── remotion/ # Compositions vidéo des Reels (aperçu + rendu)
├── server/ # Backend Express.js (TypeScript) ├── server/ # Backend Rust (axum + sqlx)
│ ├── routes.ts # 45+ endpoints REST API │ ├── Cargo.toml
│ ├── routes/ # Routes modulaires (analytics, reels) │ └── src/
│ ├── services/ # 12 services métier │ ├── main.rs # Démarrage : migrations, routes, tâches de fond
│ │ ├── analytics.ts # Sync Facebook Graph API │ ├── migrate.rs # Schéma PostgreSQL, mis à jour au démarrage
│ │ ├── facebook.ts # Publication Facebook/Instagram │ ├── auth.rs # Sessions (cookie connect.sid) et rôles
│ │ ├── openrouter.ts # Génération texte IA │ ├── db/ # Accès aux données
│ │ ├── cloudinary.ts # Upload cloud médias │ ├── routes/ # API REST (/api, /api/v1 externe, pages légales)
│ │ ├── ffmpeg.ts # Encodage vidéo (microservice) │ └── services/ # Facebook, TikTok, OpenRouter, Reels, scheduler, crons
│ │ ├── freesound.ts # Musique libre de droits ├── shared/ # Types et logique partagés par le frontend
│ │ ├── jamendo.ts # Musique libre de droits ├── remotion-service/ # Rendu des Reels avec Remotion (Node + Chrome)
│ │ ├── imageProcessor.ts # Traitement images (Sharp)
│ │ ├── scheduler.ts # Publications programmées
│ │ └── token_manager.ts # Gestion tokens OAuth
│ └── cron.ts # Tâches planifiées
├── shared/ # Schéma DB partagé (Drizzle ORM)
├── ffmpeg-service/ # Microservice FFmpeg (Python/FastAPI) ├── ffmpeg-service/ # Microservice FFmpeg (Python/FastAPI)
├── docker-compose.yml # Stack Docker (3 services) ├── qwen-tts/ # Voix Qwen3-TTS (optionnel)
└── Dockerfile # Image production Node.js ├── docker-compose.yml # Stack Docker
└── Dockerfile # Image de l'application (Rust + frontend compilé)
``` ```
### Stack Technique ### Stack Technique
| Couche | Technologies | | Couche | Technologies |
| ---------- | --------------------------------------------------------------------------- | | ---------- | --------------------------------------------------------------------------- |
| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Framer Motion, Wouter | | **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Wouter |
| **Backend** | Express.js, TypeScript, Passport.js, Helmet, node-cron | | **Backend** | Rust : axum, tokio, sqlx, reqwest |
| **BDD** | PostgreSQL 16, Drizzle ORM, connect-pg-simple (sessions) | | **BDD** | PostgreSQL 16 (schéma créé et migré automatiquement au démarrage) |
| **IA** | OpenRouter (multi-modèle : GPT-4, Claude, Gemini, etc.) | | **IA** | OpenRouter (multi-modèle : GPT-4, Claude, Gemini, etc.) |
| **Médias** | Cloudinary (upload), Sharp (images), FFmpeg microservice (vidéo) | | **Médias** | Stockage local, FFmpeg (vignettes, microservice vidéo), Remotion (Reels) |
| **Musique** | FreeSound API, Jamendo API | | **Réseaux** | Facebook Graph API (Pages, Instagram, Insights), TikTok Content Posting |
| **Réseaux** | Facebook Graph API v19 (Pages, Instagram, Insights) |
| **Infra** | Docker Compose, Nginx reverse proxy | | **Infra** | Docker Compose, Nginx reverse proxy |
### Mise à jour
L'image de l'application est construite à partir des sources (`pull_policy: build`).
Après un `git pull`, le prochain démarrage de la stack reconstruit l'image et
applique la nouvelle version :
```bash
git pull
docker compose up -d --build
```
Au démarrage, le serveur met le schéma de la base à jour tout seul (aucune
commande de migration à lancer). Les données, les médias, la clé de chiffrement
et les sessions ouvertes sont conservés.
--- ---
## 🚀 Installation Rapide ## 🚀 Installation Rapide
@@ -161,7 +171,7 @@ socialflow/
### Prérequis ### Prérequis
- [Docker](https://www.docker.com/) & Docker Compose - [Docker](https://www.docker.com/) & Docker Compose
- [Node.js](https://nodejs.org/) v18+ (pour le développement local) - Pour le développement local : [Rust](https://rustup.rs/) (stable), [Node.js](https://nodejs.org/) 20+ et PostgreSQL
### 1. Récupérer l'application ### 1. Récupérer l'application
@@ -199,11 +209,12 @@ FACEBOOK_APP_SECRET=...
```bash ```bash
# Production (Docker) # Production (Docker)
docker-compose up -d docker compose up -d --build
# Développement local # Développement local (deux terminaux)
npm install npm install
npm run dev DATABASE_URL=postgresql://... npm run dev:server # API Rust sur le port 5000
npm run dev # Frontend Vite, relaie /api vers le port 5000
``` ```
🚀 Rendez-vous sur **`http://localhost:5555`** pour commencer ! 🚀 Rendez-vous sur **`http://localhost:5555`** pour commencer !
+12 -78
View File
@@ -42,13 +42,12 @@ La clé externe peut être configurée par un administrateur depuis l'interface
5. [Gestion de l'Audio (Musique de fond)](#5-gestion-de-laudio-musique-de-fond) 5. [Gestion de l'Audio (Musique de fond)](#5-gestion-de-laudio-musique-de-fond)
6. [Publications & Planification](#6-publications--planification) 6. [Publications & Planification](#6-publications--planification)
7. [Reels & Génération Vidéo (FFmpeg)](#7-reels--génération-vidéo-ffmpeg) 7. [Reels & Génération Vidéo (FFmpeg)](#7-reels--génération-vidéo-ffmpeg)
8. [Génération Vidéo Avancée (Remotion)](#8-génération-vidéo-avancée-remotion) 8. [IA & Modèles (OpenRouter)](#8-ia--modèles-openrouter)
9. [IA & Modèles (OpenRouter)](#9-ia--modèles-openrouter) 9. [Statistiques & Paramètres Système](#9-statistiques--paramètres-système)
10. [Statistiques & Paramètres Système](#10-statistiques--paramètres-système) 10. [Console SQL (Admin uniquement)](#10-console-sql-admin-uniquement)
11. [Console SQL (Admin uniquement)](#11-console-sql-admin-uniquement) 11. [API d'Analyse (Analytics)](#11-api-danalyse-analytics)
12. [API d'Analyse (Analytics)](#12-api-danalyse-analytics) 12. [API Développeur Externe (v1)](#12-api-développeur-externe-v1)
13. [API Développeur Externe (v1)](#13-api-développeur-externe-v1) 13. [Microservice Interne FFmpeg (Python)](#13-microservice-interne-ffmpeg-python)
14. [Microservice Interne FFmpeg (Python)](#14-microservice-interne-ffmpeg-python)
--- ---
@@ -727,72 +726,7 @@ GET /api/reels/status/:postId
--- ---
## 8. Génération Vidéo Avancée (Remotion) ## 8. IA & Modèles (OpenRouter)
Remotion permet d'assembler des diaporamas complexes et des vidéos à partir d'images à l'aide d'un navigateur Chromium sans tête (headless).
### Lancer un rendu de vidéo diaporama Remotion
```http
POST /api/remotion/render
```
**Body (Multipart Form) :**
- `existingImageUrls` : (Facultatif) Tableau d'URL d'images déjà enregistrées en base (converties directement en base64 pour contourner les limitations de requêtes réseau Chromium sous Docker).
- `images` : Fichiers images téléversés en direct.
- `music` : Fichier de musique téléversé.
- `musicTrackUrl` : (Facultatif) URL de musique existante.
- `overlayText` : Texte de sous-titre pour la synthèse vocale intégrée.
- `musicVolume` : Volume sonore (ex: `0.3`).
**Réponse (200 OK) :**
```json
{
"jobId": "remotion_job_171691",
"message": "Rendu démarré en arrière-plan"
}
```
### Vérifier le statut du rendu Remotion
```http
GET /api/remotion/render/status/:jobId
```
**Réponse (200 OK) :**
```json
{
"status": "done",
"url": "/uploads/temp/remotion-171691.mp4",
"thumbnailUrl": "/uploads/temp/remotion-171691-thumb.jpg",
"error": null
}
```
### Publier le diaporama généré comme Reel
Téléverse le fichier MP4 finalisé vers le stockage puis publie la vidéo sur les pages cibles.
```http
POST /api/remotion/publish
```
**Body (JSON) :**
```json
{
"videoUrl": "/uploads/temp/remotion-171691.mp4",
"pageIds": ["page_999"],
"description": "Mon diaporama animé créé avec Remotion !",
"scheduledFor": "2026-06-01T12:00:00Z"
}
```
**Réponse (200 OK) :**
```json
{
"success": true,
"postId": "pst_remotion_999",
"results": [
{ "pageId": "page_999", "success": true, "reelId": "scheduled" }
]
}
```
---
## 9. IA & Modèles (OpenRouter)
### Liste des modèles d'écriture IA disponibles ### Liste des modèles d'écriture IA disponibles
```http ```http
@@ -839,7 +773,7 @@ GET /api/ai/generations
--- ---
## 10. Statistiques & Paramètres Système ## 9. Statistiques & Paramètres Système
### Récupérer les statistiques du tableau de bord ### Récupérer les statistiques du tableau de bord
Calcule les publications planifiées, le nombre de pages connectées, le total de textes générés, l'espace de stockage des médias et les tendances d'évolution. Calcule les publications planifiées, le nombre de pages connectées, le total de textes générés, l'espace de stockage des médias et les tendances d'évolution.
@@ -888,7 +822,7 @@ GET /api/stats
--- ---
## 11. Console SQL (Admin uniquement) ## 10. Console SQL (Admin uniquement)
Ces outils exclusifs aux administrateurs permettent de déboguer ou de faire des opérations de maintenance directement sur la base de données PostgreSQL. Ces outils exclusifs aux administrateurs permettent de déboguer ou de faire des opérations de maintenance directement sur la base de données PostgreSQL.
@@ -931,7 +865,7 @@ GET /api/sql/tables
--- ---
## 12. API d'Analyse (Analytics) ## 11. API d'Analyse (Analytics)
Récupération et mise à jour des statistiques de performance des publications et des pages (portée, clics, croissance d'abonnés). Récupération et mise à jour des statistiques de performance des publications et des pages (portée, clics, croissance d'abonnés).
@@ -1006,7 +940,7 @@ POST /api/analytics/tokens/check
--- ---
## 13. API Développeur Externe (v1) ## 12. API Développeur Externe (v1)
Ces routes sont optimisées pour les appels de scripts d'intégration tierce. Ces routes sont optimisées pour les appels de scripts d'intégration tierce.
Toutes les requêtes doivent contenir le header `X-API-Key`. Toutes les requêtes doivent contenir le header `X-API-Key`.
@@ -1103,7 +1037,7 @@ DELETE /api/v1/posts/:id
--- ---
## 14. Microservice Interne FFmpeg (Python) ## 13. Microservice Interne FFmpeg (Python)
Ce service s'exécute sur le port `8000` et gère le pipeline de rendu lourd FFmpeg. Ce service s'exécute sur le port `8000` et gère le pipeline de rendu lourd FFmpeg.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

BIN
View File
Binary file not shown.
+14 -2
View File
@@ -4,11 +4,23 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Social Flow - Automatisation des réseaux sociaux</title> <title>Social Flow - Automatisation des réseaux sociaux</title>
<meta name="description" content="Automatisez vos publications Facebook et Instagram avec Social Flow. Génération de contenu par IA, planification et publication automatique." /> <meta name="description" content="Automatisez vos publications Facebook, Instagram et TikTok avec Social Flow. Génération de contenu par IA, planification et publication automatique." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#4F46E5" />
<script>
// Applique le thème avant le premier rendu pour éviter un flash.
(function () {
try {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9 -17
View File
@@ -1,21 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs> <defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="g" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" /> <stop offset="0" stop-color="#4F46E5"/>
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:1" /> <stop offset="0.6" stop-color="#7C3AED"/>
<stop offset="1" stop-color="#F97362"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="32" height="32" rx="9" fill="url(#g)"/>
<!-- Background circle --> <path d="M7 12.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" stroke-opacity="0.55" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="50" cy="50" r="48" fill="url(#grad1)"/> <path d="M7 19.5c2.4-3.6 5.1-3.6 7.5 0s5.1 3.6 7.5 0" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<circle cx="25.2" cy="16" r="1.9" fill="#fff"/>
<!-- Social network icon - simplified nodes and connections -->
<circle cx="30" cy="35" r="8" fill="white" opacity="0.9"/>
<circle cx="70" cy="35" r="8" fill="white" opacity="0.9"/>
<circle cx="50" cy="65" r="8" fill="white" opacity="0.9"/>
<!-- Connecting lines -->
<line x1="30" y1="35" x2="50" y2="65" stroke="white" stroke-width="3" opacity="0.7"/>
<line x1="70" y1="35" x2="50" y2="65" stroke="white" stroke-width="3" opacity="0.7"/>
<line x1="30" y1="35" x2="70" y2="35" stroke="white" stroke-width="3" opacity="0.7"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 705 B

+69 -112
View File
@@ -1,98 +1,58 @@
import { Switch, Route, Redirect, useLocation } from "wouter"; import { Switch, Route, useLocation, Link } from "wouter";
import { useEffect } from "react"; import { lazy, Suspense, useEffect, type ComponentType } from "react";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
import { QueryClientProvider, useQuery } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ShieldAlert } from "lucide-react";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { createResponsiveRoute } from "@/components/responsive-route"; import { Button } from "@/components/ui/button";
import { ThemeProvider } from "@/components/theme-provider";
import { AppShell, Page } from "@/components/layout/app-shell";
import { EmptyState } from "@/components/empty-state";
import { LogoMark } from "@/components/brand/logo";
import { useSession } from "@/hooks/use-session";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import Login from "@/pages/login"; import Login from "@/pages/login";
import TestCamera from "@/pages/test-camera";
// Pages chargées à la demande : une seule version responsive par écran.
const Dashboard = lazy(() => import("@/pages/dashboard"));
const NewPost = lazy(() => import("@/pages/new-post"));
const NewReel = lazy(() => import("@/pages/new-reel"));
const ScheduleReel = lazy(() => import("@/pages/schedule-reel"));
const Calendar = lazy(() => import("@/pages/calendar"));
const Media = lazy(() => import("@/pages/media"));
const ImageEditor = lazy(() => import("@/pages/image-editor"));
const PagesManagement = lazy(() => import("@/pages/pages"));
const AI = lazy(() => import("@/pages/ai"));
const History = lazy(() => import("@/pages/history"));
const Settings = lazy(() => import("@/pages/settings"));
const SqlAdmin = lazy(() => import("@/pages/sql"));
const AudioAdmin = lazy(() => import("@/pages/audio-admin"));
const UsersAdmin = lazy(() => import("@/pages/users-admin"));
const Analytics = lazy(() => import("@/pages/analytics"));
// Create responsive routes with lazy loading for optimal performance function FullScreenLoader() {
const History = createResponsiveRoute( return (
() => import("@/pages/history"), <div className="flex h-screen items-center justify-center bg-background">
() => import("@/pages/mobile/history") <div className="flex flex-col items-center gap-4">
); <LogoMark size={44} className="animate-pulse" />
const Dashboard = createResponsiveRoute( <p className="text-sm text-muted-foreground">Chargement…</p>
() => import("@/pages/dashboard"), </div>
() => import("@/pages/mobile/dashboard") </div>
); );
}
const NewPost = createResponsiveRoute( function PageLoader() {
() => import("@/pages/new-post"), return (
() => import("@/pages/mobile/new-post") <div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
); );
}
const Calendar = createResponsiveRoute( function ProtectedRoute({ component: Component, adminOnly = false }: { component: ComponentType; adminOnly?: boolean }) {
() => import("@/pages/calendar"),
() => import("@/pages/mobile/calendar")
);
const Media = createResponsiveRoute(
() => import("@/pages/media"),
() => import("@/pages/mobile/media")
);
const ImageEditor = createResponsiveRoute(
() => import("@/pages/image-editor"),
() => import("@/pages/mobile/image-editor")
);
const PagesManagement = createResponsiveRoute(
() => import("@/pages/pages"),
() => import("@/pages/mobile/pages")
);
const AI = createResponsiveRoute(
() => import("@/pages/ai"),
() => import("@/pages/mobile/ai")
);
const Settings = createResponsiveRoute(
() => import("@/pages/settings"),
() => import("@/pages/mobile/settings")
);
const SqlAdmin = createResponsiveRoute(
() => import("@/pages/sql"),
() => import("@/pages/mobile/sql")
);
const AudioAdmin = createResponsiveRoute(
() => import("@/pages/audio-admin"),
() => import("@/pages/mobile/audio-admin")
);
const UsersAdmin = createResponsiveRoute(
() => import("@/pages/users-admin"),
() => import("@/pages/mobile/users-admin")
);
const Analytics = createResponsiveRoute(
() => import("@/pages/AnalyticsPage"),
() => import("@/pages/mobile/analytics")
);
// NewReel uses desktop version for now (mobile can be added later)
const NewReel = createResponsiveRoute(
() => import("@/pages/new-reel"),
() => import("@/pages/mobile/new-reel") // Uses same component for now
);
const RemotionVideo = createResponsiveRoute(
() => import("@/pages/remotion-video"),
() => import("@/pages/mobile/remotion-video")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const { session, isAdmin, isLoading } = useSession();
const { data: session, isLoading } = useQuery({
queryKey: ["/api/auth/session"],
retry: false,
});
useEffect(() => { useEffect(() => {
if (!isLoading && !session) { if (!isLoading && !session) {
@@ -100,35 +60,33 @@ function ProtectedRoute({ component: Component, adminOnly = false }: { component
} }
}, [isLoading, session, setLocation]); }, [isLoading, session, setLocation]);
if (isLoading) { if (isLoading) return <FullScreenLoader />;
if (!session) return null;
return ( return (
<div className="flex items-center justify-center h-screen bg-background"> <AppShell>
<div className="text-center"> {adminOnly && !isAdmin ? (
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div> <Page width="narrow">
<p className="text-muted-foreground">Chargement...</p> <EmptyState
</div> icon={ShieldAlert}
</div> title="Accès réservé aux administrateurs"
description="Demandez à un administrateur de vous donner accès à cette page."
action={
<Button asChild variant="outline">
<Link href="/">Retour au tableau de bord</Link>
</Button>
}
/>
</Page>
) : (
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
)}
</AppShell>
); );
} }
if (!session) {
return null;
}
if (adminOnly && (session as any).role !== "admin") {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Accès refusé</h1>
<p className="text-muted-foreground">Cette page est réservée aux administrateurs.</p>
</div>
</div>
);
}
return <Component />;
}
function Router() { function Router() {
return ( return (
<Switch> <Switch>
@@ -136,7 +94,7 @@ function Router() {
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route> <Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route> <Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route> <Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route> <Route path="/reel/schedule">{() => <ProtectedRoute component={ScheduleReel} />}</Route>
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route> <Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route> <Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route> <Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
@@ -148,7 +106,6 @@ function Router() {
<Route path="/audio-admin">{() => <ProtectedRoute component={AudioAdmin} adminOnly />}</Route> <Route path="/audio-admin">{() => <ProtectedRoute component={AudioAdmin} adminOnly />}</Route>
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route> <Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route> <Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
<Route path="/test-camera" component={TestCamera} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
); );
@@ -156,14 +113,14 @@ function Router() {
function App() { function App() {
return ( return (
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<div>
<Toaster /> <Toaster />
<Router /> <Router />
</div>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
); );
} }
+75 -93
View File
@@ -4,7 +4,8 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; 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 { useToast } from "@/hooks/use-toast";
import { ModelCombobox } from "@/components/model-combobox"; import { ModelCombobox } from "@/components/model-combobox";
@@ -38,6 +39,9 @@ export default function AiChat() {
// Fetch available models from OpenRouter // Fetch available models from OpenRouter
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({ const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const availableModels = modelsData?.data || []; const availableModels = modelsData?.data || [];
@@ -87,33 +91,20 @@ export default function AiChat() {
setInput(""); setInput("");
}; };
return ( const copyVariant = async (text: string) => {
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col"> try {
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5"> await navigator.clipboard.writeText(text);
<div className="flex items-center justify-between mb-4"> toast({ title: "Texte copié", description: "Collez-le dans votre publication." });
<div className="flex items-center gap-4"> } catch {
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg"> toast({ title: "Copie impossible", description: "Sélectionnez le texte manuellement.", variant: "destructive" });
<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"> return (
<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 <ModelCombobox
models={availableModels} models={availableModels}
value={selectedModel} value={selectedModel}
@@ -121,78 +112,91 @@ export default function AiChat() {
placeholder="Sélectionner un modèle" placeholder="Sélectionner un modèle"
isLoading={modelsLoading} isLoading={modelsLoading}
disabled={modelsLoading} disabled={modelsLoading}
className="w-[300px] rounded-xl" className="w-full sm:w-[300px]"
testId="select-ai-model" testId="select-ai-model"
/> />
</div> </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>
<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) => ( {messages.map((message, index) => (
<div <div key={index} className={`flex gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
key={index}
className={`flex gap-4 chat-message ${message.role === "user" ? "justify-end" : ""}`}
>
{message.role === "assistant" && ( {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"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Bot className="text-white w-5 h-5" /> <Bot className="h-4 w-4" />
</div> </div>
)} )}
<div className={`flex-1 ${message.role === "user" ? "max-w-md ml-auto" : "max-w-2xl"}`}> <div className={message.role === "user" ? "max-w-[85%] sm:max-w-md" : "max-w-full flex-1 sm:max-w-2xl"}>
<div className={` <div
rounded-2xl p-6 shadow-md className={
${message.role === "user" message.role === "user"
? "bg-gradient-to-br from-primary to-secondary text-white ml-auto" ? "rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-primary-foreground"
: "bg-card border border-border/50" : "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 && ( {message.variants && (
<div className="space-y-4 mt-6"> <div className="mt-4 space-y-3">
{message.variants.map((variant, vIndex) => ( {message.variants.map((variant, vIndex) => (
<div <button
type="button"
key={vIndex} 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}`} data-testid={`variant-${vIndex}`}
> >
<div className="flex items-center justify-between mb-3"> <div className="mb-2 flex items-center justify-between gap-2">
<span className={` <Badge variant={vIndex === 0 ? "default" : "muted"}>{variant.variant}</Badge>
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
${vIndex === 0 ? "bg-gradient-to-r from-primary to-secondary text-white" : "bg-secondary/20 text-secondary"} {variant.characterCount} caractères
`}> <Copy className="h-3.5 w-3.5 opacity-60 group-hover:opacity-100" />
{variant.variant}
</span> </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> </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>
)} )}
</div> </div>
<span className="text-xs text-muted-foreground mt-2 inline-block">
{message.role === "user" ? "Maintenant" : "Il y a quelques secondes"}
</span>
</div> </div>
{message.role === "user" && ( {message.role === "user" && (
<div className="w-10 h-10 rounded-xl bg-secondary/20 flex-shrink-0 flex items-center justify-center"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<User className="text-secondary w-5 h-5" /> <User className="h-4 w-4" />
</div> </div>
)} )}
</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>
<div className="p-6 border-t border-border/50 bg-card space-y-4"> <div className="border-t bg-card p-3 sm:p-4">
<div className="flex gap-4"> <div className="flex items-end gap-2">
<Textarea <Textarea
placeholder="Décrivez votre produit (nom, prix, caractéristiques)..." placeholder="Décrivez votre produit (nom, prix, caractéristiques)…"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} 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) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -204,39 +208,17 @@ export default function AiChat() {
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={generateMutation.isPending || !input.trim()} 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" data-testid="button-generate"
> >
<Sparkles className="w-4 h-4 mr-2" /> {generateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{generateMutation.isPending ? "Génération..." : "Générer"} <span className="hidden sm:inline">Générer</span>
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <p className="mt-2 hidden text-xs text-muted-foreground sm:block">
<Button Entrée pour envoyer · Maj + Entrée pour aller à la ligne
variant="outline" </p>
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>
</div> </div>
</div> </div>
); );
@@ -2,6 +2,9 @@ import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MetricsCard } from './MetricsCard'; import { MetricsCard } from './MetricsCard';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Link } from 'wouter';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/empty-state';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react'; import { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
@@ -98,15 +101,34 @@ export function AnalyticsDashboard() {
return Math.round(((current - prev) / prev) * 100); return Math.round(((current - prev) / prev) * 100);
}; };
if (isLoadingPages) return <div>Chargement des pages...</div>; if (isLoadingPages) {
if (!pages.length) return <div>Aucune page connectée. Veuillez connecter une page Facebook dans les Paramètres.</div>; return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-28 rounded-xl" />)}
</div>
);
}
if (!pages.length) {
return (
<EmptyState
icon={BarChart3}
title="Aucun compte connecté"
description="Connectez une page Facebook pour suivre son audience et son engagement."
action={
<Button asChild>
<Link href="/pages">Connecter un compte</Link>
</Button>
}
/>
);
}
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<Select value={selectedPageId || ''} onValueChange={setSelectedPageId}> <Select value={selectedPageId || ''} onValueChange={setSelectedPageId}>
<SelectTrigger className="w-[250px]"> <SelectTrigger className="w-full sm:w-[260px]">
<SelectValue placeholder="Sélectionner une page" /> <SelectValue placeholder="Sélectionner une page" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -119,9 +141,9 @@ export function AnalyticsDashboard() {
</Select> </Select>
{selectedPage && ( {selectedPage && (
<span className={`px-2 py-1 rounded text-xs ${selectedPage.tokenStatus === 'valid' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}> <Badge variant={selectedPage.tokenStatus === 'valid' ? 'success' : 'danger'} className="shrink-0">
Token : {selectedPage.tokenStatus || 'Inconnu'} {selectedPage.tokenStatus === 'valid' ? 'Connexion active' : 'Connexion à vérifier'}
</span> </Badge>
)} )}
</div> </div>
@@ -130,23 +152,23 @@ 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
</Button> </Button>
</div> </div>
{selectedPageId && ( {selectedPageId && (
<> <>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<MetricsCard <MetricsCard
title="Abonnés Totaux" title="Abonnés"
value={latest?.followersCount?.toLocaleString() || 0} value={latest?.followersCount?.toLocaleString() || 0}
icon={<Users className="h-4 w-4 text-muted-foreground" />} icon={<Users className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.followersCount, previous.followersCount) : undefined} trend={latest && previous ? getTrend(latest.followersCount, previous.followersCount) : undefined}
description="depuis la dernière mise à jour" description="depuis la dernière mise à jour"
/> />
<MetricsCard <MetricsCard
title="Portée de la Page" title="Portée"
value={latest?.pageReach?.toLocaleString() || 0} value={latest?.pageReach?.toLocaleString() || 0}
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />} icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined} trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
@@ -160,7 +182,7 @@ export function AnalyticsDashboard() {
description="interactions totales (jour)" description="interactions totales (jour)"
/> />
<MetricsCard <MetricsCard
title="Vues de Page" title="Vues de la page"
value={(latest?.pageViews || 0).toLocaleString()} value={(latest?.pageViews || 0).toLocaleString()}
icon={<Eye className="h-4 w-4 text-muted-foreground" />} icon={<Eye className="h-4 w-4 text-muted-foreground" />}
trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined} trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined}
@@ -170,22 +192,22 @@ export function AnalyticsDashboard() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Historique de Croissance &amp; Portée</CardTitle> <CardTitle>Évolution</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis dataKey="date" /> <XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<YAxis yAxisId="left" /> <YAxis yAxisId="left" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<YAxis yAxisId="right" orientation="right" /> <YAxis yAxisId="right" orientation="right" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip /> <Tooltip contentStyle={{ background: "hsl(var(--popover))", border: "1px solid hsl(var(--border))", borderRadius: 8, color: "hsl(var(--popover-foreground))" }} />
<Legend /> <Legend />
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Abonnés" /> <Line yAxisId="left" type="monotone" dataKey="followers" strokeWidth={2} dot={false} stroke="hsl(var(--chart-1))" name="Abonnés" />
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Portée" /> <Line yAxisId="right" type="monotone" dataKey="reach" strokeWidth={2} dot={false} stroke="hsl(var(--chart-3))" name="Portée" />
<Line yAxisId="right" type="monotone" dataKey="engagement" stroke="#ff7f50" name="Engagement" /> <Line yAxisId="right" type="monotone" dataKey="engagement" strokeWidth={2} dot={false} stroke="hsl(var(--chart-2))" name="Engagement" />
<Line yAxisId="right" type="monotone" dataKey="views" stroke="#ffa500" name="Vues" /> <Line yAxisId="right" type="monotone" dataKey="views" strokeWidth={2} dot={false} stroke="hsl(var(--chart-5))" name="Vues" />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -13,17 +13,17 @@ export function MetricsCard({ title, value, description, trend, icon }: MetricsC
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium text-muted-foreground">
{title} {title}
</CardTitle> </CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>} {icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-semibold tabular-nums">{value}</div>
{(trend !== undefined || description) && ( {(trend !== undefined || description) && (
<p className="text-xs text-muted-foreground mt-1 flex items-center"> <p className="text-xs text-muted-foreground mt-1 flex items-center">
{trend !== undefined && ( {trend !== undefined && (
<span className={`flex items-center mr-2 ${trend > 0 ? 'text-green-500' : trend < 0 ? 'text-red-500' : ''}`}> <span className={`flex items-center mr-2 ${trend > 0 ? 'text-success' : trend < 0 ? 'text-destructive' : ''}`}>
{trend > 0 ? <ArrowUpIcon className="w-3 h-3 mr-1" /> : trend < 0 ? <ArrowDownIcon className="w-3 h-3 mr-1" /> : <MinusIcon className="w-3 h-3 mr-1" />} {trend > 0 ? <ArrowUpIcon className="w-3 h-3 mr-1" /> : trend < 0 ? <ArrowDownIcon className="w-3 h-3 mr-1" /> : <MinusIcon className="w-3 h-3 mr-1" />}
{Math.abs(trend)}% {Math.abs(trend)}%
</span> </span>
+56
View File
@@ -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>
);
}
+8 -8
View File
@@ -1,7 +1,7 @@
import { format, isSameDay, parseISO } from "date-fns"; import { format, isSameDay, parseISO } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react"; import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState } from "react"; import { useState } from "react";
@@ -159,13 +159,13 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
key={post.id} key={post.id}
className={`px-4 py-3 border-b border-border/30 last:border-b-0 ${ className={`px-4 py-3 border-b border-border/30 last:border-b-0 ${
isPending ? 'bg-blue-500/5' : 'bg-green-500/5' isPending ? 'bg-primary/5' : 'bg-success/5'
}`} }`}
data-testid={`calendar-list-post-${post.id}`} data-testid={`calendar-list-post-${post.id}`}
> >
@@ -176,8 +176,8 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
<span className="font-semibold text-lg text-foreground">{time}</span> <span className="font-semibold text-lg text-foreground">{time}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${ <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
isPending isPending
? 'bg-blue-500/20 text-blue-600' ? 'bg-primary/10 text-primary'
: 'bg-green-500/20 text-green-600' : 'bg-success/15 text-success'
}`}> }`}>
{isPending ? 'Programmé' : 'Publié'} {isPending ? 'Programmé' : 'Publié'}
</span> </span>
@@ -197,7 +197,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-preview-post-${post.id}`} data-testid={`button-preview-post-${post.id}`}
title="Prévisualiser" title="Prévisualiser"
> >
<Eye className={`w-5 h-5 ${isPending ? 'text-blue-500' : 'text-green-500'}`} /> <Eye className={`w-5 h-5 ${isPending ? 'text-primary' : 'text-success'}`} />
</Button> </Button>
{isPending && ( {isPending && (
<> <>
@@ -209,7 +209,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-edit-post-${post.id}`} data-testid={`button-edit-post-${post.id}`}
title="Modifier" title="Modifier"
> >
<Edit className="w-5 h-5 text-blue-500" /> <Edit className="w-5 h-5 text-primary" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -219,7 +219,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
data-testid={`button-delete-post-${post.id}`} data-testid={`button-delete-post-${post.id}`}
title="Supprimer" title="Supprimer"
> >
<Trash2 className="w-5 h-5 text-red-500" /> <Trash2 className="w-5 h-5 text-destructive" />
</Button> </Button>
</> </>
)} )}
+73 -55
View File
@@ -1,18 +1,36 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone } from "lucide-react"; import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone, Clapperboard } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { ScheduledPost } from "@shared/schema"; import type { ScheduledPost } from "@shared/schema";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import EditScheduledPostDialog from "./edit-scheduled-post-dialog"; import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
import CalendarListView from "./calendar-list-view"; import CalendarListView from "./calendar-list-view";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import type { Media } from "@shared/schema"; import type { Media } from "@shared/schema";
/**
* Bornes de la grille affichée : 42 cases à partir du lundi qui précède le 1er
* du mois. Demander cette fenêtre plutôt que tout l'historique évite de charger
* des années de publications pour en afficher six semaines.
*/
function visibleRange(currentDate: Date): { startDate: string; endDate: string } {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
const start = new Date(year, month, 1 - startingDayOfWeek);
const end = new Date(year, month, 1 - startingDayOfWeek + 42);
end.setMilliseconds(-1); // fin de la 42e journée
return { startDate: start.toISOString(), endDate: end.toISOString() };
}
export default function CalendarView() { export default function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -22,8 +40,11 @@ export default function CalendarView() {
const { toast } = useToast(); const { toast } = useToast();
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// La fenêtre fait partie de la clé : changer de mois redéclenche la requête.
const range = visibleRange(currentDate);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ["/api/scheduled-posts"], queryKey: ["/api/scheduled-posts", range],
refetchInterval: 30000, // Auto-refresh every 30 seconds refetchInterval: 30000, // Auto-refresh every 30 seconds
}); });
@@ -105,6 +126,8 @@ export default function CalendarView() {
<Smartphone className="w-2.5 h-2.5" /> <Smartphone className="w-2.5 h-2.5" />
</div> </div>
); );
} else if (postType === 'reel') {
return <Clapperboard className="w-3 h-3" />;
} }
return null; return null;
}; };
@@ -170,52 +193,47 @@ export default function CalendarView() {
}; };
return ( return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg"> <div className="overflow-hidden rounded-xl border bg-card shadow-soft">
<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 gap-3 border-b px-4 py-3 sm:px-6">
<div className="flex items-center justify-between"> <h2 className="text-lg font-semibold capitalize text-foreground">
<div className="flex items-center gap-4"> {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg"> </h2>
<CalendarIcon className="text-white w-6 h-6" /> <div className="flex items-center gap-1">
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Calendrier</h3>
<p className="text-sm text-muted-foreground">Visualisez vos publications planifiées</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentDate(new Date())}
data-testid="button-today"
>
Aujourd'hui
</Button>
<Button
variant="ghost"
size="icon"
onClick={goToPreviousMonth} onClick={goToPreviousMonth}
className="rounded-xl" aria-label="Mois précédent"
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
</Button> </Button>
<span className="text-sm font-semibold text-foreground min-w-[140px] text-center px-4 py-2 bg-muted/30 rounded-xl">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
onClick={goToNextMonth} onClick={goToNextMonth}
className="rounded-xl" aria-label="Mois suivant"
data-testid="button-next-month" data-testid="button-next-month"
> >
Suivant <ChevronRight className="w-4 h-4" />
<ChevronRight className="w-4 h-4 ml-2" />
</Button> </Button>
</div> </div>
</div> </div>
</div>
{isDesktop ? ( {isDesktop ? (
<div className="p-8"> <div className="p-4 lg:p-6">
<div className="grid grid-cols-7 gap-2 mb-2"> <div className="grid grid-cols-7 gap-2 mb-2">
{daysOfWeek.map((day) => ( {daysOfWeek.map((day) => (
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center"> <div key={day} className="py-1 text-center">
<span className="text-sm font-semibold text-muted-foreground">{day}</span> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{day}</span>
</div> </div>
))} ))}
</div> </div>
@@ -225,8 +243,8 @@ export default function CalendarView() {
<div <div
key={index} key={index}
className={` className={`
bg-card border border-border/50 rounded-xl p-4 min-h-[140px] transition-all hover:shadow-md bg-card border rounded-lg p-2.5 min-h-[120px] transition-colors hover:border-primary/30
${isToday(day.date) ? "ring-2 ring-primary shadow-lg" : ""} ${isToday(day.date) ? "border-primary/60 bg-primary/[0.03]" : ""}
${!day.isCurrentMonth ? "opacity-40" : ""} ${!day.isCurrentMonth ? "opacity-40" : ""}
`} `}
data-testid={`calendar-day-${index}`} data-testid={`calendar-day-${index}`}
@@ -242,7 +260,7 @@ export default function CalendarView() {
{day.date.getDate()} {day.date.getDate()}
</span> </span>
{isToday(day.date) && ( {isToday(day.date) && (
<span className="text-[10px] bg-gradient-to-r from-primary to-secondary text-white px-2 py-0.5 rounded-full font-semibold"> <span className="text-[10px] bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-semibold">
Aujourd'hui Aujourd'hui
</span> </span>
)} )}
@@ -261,15 +279,15 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
key={idx} key={idx}
className={` className={`
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''} ${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''} ${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
`} `}
data-testid={`calendar-post-${post.id}`} data-testid={`calendar-post-${post.id}`}
> >
@@ -287,29 +305,29 @@ export default function CalendarView() {
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => handlePreviewPost(post, e)} onClick={(e) => handlePreviewPost(post, e)}
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`} className={`p-1 rounded ${isPending ? 'hover:bg-primary/15' : 'hover:bg-success/20'}`}
data-testid={`button-preview-post-${post.id}`} data-testid={`button-preview-post-${post.id}`}
title="Prévisualiser" title="Prévisualiser"
> >
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} /> <Eye className={`w-3 h-3 ${isPending ? 'text-primary' : 'text-success'}`} />
</button> </button>
{isPending && ( {isPending && (
<> <>
<button <button
onClick={(e) => handleEditPost(post, e)} onClick={(e) => handleEditPost(post, e)}
className="p-1 hover:bg-blue-500/20 rounded" className="p-1 hover:bg-primary/15 rounded"
data-testid={`button-edit-post-${post.id}`} data-testid={`button-edit-post-${post.id}`}
title="Modifier" title="Modifier"
> >
<Edit className="w-3 h-3 text-blue-500" /> <Edit className="w-3 h-3 text-primary" />
</button> </button>
<button <button
onClick={(e) => handleDeletePost(post.id, e)} onClick={(e) => handleDeletePost(post.id, e)}
className="p-1 hover:bg-red-500/20 rounded" className="p-1 hover:bg-destructive/15 rounded"
data-testid={`button-delete-post-${post.id}`} data-testid={`button-delete-post-${post.id}`}
title="Supprimer" title="Supprimer"
> >
<Trash2 className="w-3 h-3 text-red-500" /> <Trash2 className="w-3 h-3 text-destructive" />
</button> </button>
</> </>
)} )}
@@ -340,15 +358,15 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
key={idx} key={idx}
className={` className={`
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''} ${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''} ${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
`} `}
data-testid={`popover-post-${post.id}`} data-testid={`popover-post-${post.id}`}
> >
@@ -366,29 +384,29 @@ export default function CalendarView() {
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
onClick={(e) => handlePreviewPost(post, e)} onClick={(e) => handlePreviewPost(post, e)}
className={`p-1 rounded ${isPending ? 'hover:bg-blue-500/20' : 'hover:bg-green-500/20'}`} className={`p-1 rounded ${isPending ? 'hover:bg-primary/15' : 'hover:bg-success/20'}`}
data-testid={`button-preview-popover-post-${post.id}`} data-testid={`button-preview-popover-post-${post.id}`}
title="Prévisualiser" title="Prévisualiser"
> >
<Eye className={`w-3 h-3 ${isPending ? 'text-blue-500' : 'text-green-500'}`} /> <Eye className={`w-3 h-3 ${isPending ? 'text-primary' : 'text-success'}`} />
</button> </button>
{isPending && ( {isPending && (
<> <>
<button <button
onClick={(e) => handleEditPost(post, e)} onClick={(e) => handleEditPost(post, e)}
className="p-1 hover:bg-blue-500/20 rounded" className="p-1 hover:bg-primary/15 rounded"
data-testid={`button-edit-popover-post-${post.id}`} data-testid={`button-edit-popover-post-${post.id}`}
title="Modifier" title="Modifier"
> >
<Edit className="w-3 h-3 text-blue-500" /> <Edit className="w-3 h-3 text-primary" />
</button> </button>
<button <button
onClick={(e) => handleDeletePost(post.id, e)} onClick={(e) => handleDeletePost(post.id, e)}
className="p-1 hover:bg-red-500/20 rounded" className="p-1 hover:bg-destructive/15 rounded"
data-testid={`button-delete-popover-post-${post.id}`} data-testid={`button-delete-popover-post-${post.id}`}
title="Supprimer" title="Supprimer"
> >
<Trash2 className="w-3 h-3 text-red-500" /> <Trash2 className="w-3 h-3 text-destructive" />
</button> </button>
</> </>
)} )}
@@ -408,13 +426,13 @@ export default function CalendarView() {
))} ))}
</div> </div>
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap"> <div className="mt-5 flex items-center justify-center gap-6 flex-wrap">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-blue-500/20 border-2 border-blue-500"></div> <div className="w-3 h-3 rounded bg-primary/15 border-2 border-primary"></div>
<span className="text-sm text-muted-foreground font-medium">Programmé</span> <span className="text-sm text-muted-foreground font-medium">Programmé</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-green-500/20 border-2 border-green-500"></div> <div className="w-3 h-3 rounded bg-success/15 border-2 border-success"></div>
<span className="text-sm text-muted-foreground font-medium">Publié</span> <span className="text-sm text-muted-foreground font-medium">Publié</span>
</div> </div>
</div> </div>
-303
View File
@@ -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;
+95 -28
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import type { DayContentProps } from "react-day-picker";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -7,25 +8,32 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { DayAgendaPanel } from "@/components/day-agenda-panel";
import { usePublicationAgenda } from "@/hooks/use-publication-agenda";
import { busyHours, conflictsFor, dayKey, groupByPage, occupiesSlot } from "@shared/agenda";
interface DateTimePickerProps { interface DateTimePickerProps {
value: Date | undefined; value: Date | undefined;
onChange: (date: Date | undefined) => void; onChange: (date: Date | undefined) => void;
occupiedDates?: Date[]; /** Magasins visés : leurs publications déjà prévues sont signalées. */
selectedPageIds?: string[];
placeholder?: string; placeholder?: string;
} }
const OPTIMAL_HOURS = [8, 12, 13, 18, 19, 20]; const OPTIMAL_HOURS = [8, 12, 13, 18, 19, 20];
export function DateTimePicker({ value, onChange, occupiedDates = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) { export function DateTimePicker({ value, onChange, selectedPageIds = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value); const [selectedDate, setSelectedDate] = useState<Date | undefined>(value);
const [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8); const [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8);
const [selectedMinute, setSelectedMinute] = useState<number>(value?.getMinutes() || 0); const [selectedMinute, setSelectedMinute] = useState<number>(value?.getMinutes() || 0);
const [month, setMonth] = useState<Date>(value ?? new Date());
const { itemsByDay } = usePublicationAgenda(month);
// Sync internal state with parent value prop // Sync internal state with parent value prop
useEffect(() => { useEffect(() => {
if (value) { if (value) {
setSelectedDate(value); setSelectedDate(value);
setMonth(value);
setSelectedHour(value.getHours()); setSelectedHour(value.getHours());
setSelectedMinute(value.getMinutes()); setSelectedMinute(value.getMinutes());
} else { } else {
@@ -63,15 +71,44 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
} }
}; };
// Convert occupied dates to a Set of date strings for efficient lookup // Publications qui occupent encore un créneau, jour par jour
const occupiedDateStrings = new Set( const activeItemsOn = useCallback(
occupiedDates.map(date => format(date, 'yyyy-MM-dd')) (date: Date) => (itemsByDay.get(dayKey(date)) ?? []).filter(occupiesSlot),
[itemsByDay]
); );
const isSelectedStoreBusy = (date: Date) => conflictsFor(activeItemsOn(date), selectedPageIds).length > 0;
const isOtherStoreBusy = (date: Date) => activeItemsOn(date).length > 0 && !isSelectedStoreBusy(date);
// Check if a date is occupied const selectedDayItems = selectedDate ? itemsByDay.get(dayKey(selectedDate)) ?? [] : [];
const isDateOccupied = (date: Date) => { const hoursTaken = busyHours(selectedDayItems, selectedPageIds);
return occupiedDateStrings.has(format(date, 'yyyy-MM-dd')); const selectedKey = selectedPageIds.join(",");
};
// Pastille du jour : publications sur les magasins choisis, sinon sur les autres
const AgendaDayContent = useCallback(
({ date }: DayContentProps) => {
const items = activeItemsOn(date);
const mine = conflictsFor(items, selectedKey ? selectedKey.split(",") : []).length;
const title = groupByPage(items)
.map(({ page, items: pageItems }) => `${page.pageName} : ${pageItems.length}`)
.join("\n");
return (
<span className="relative flex h-full w-full items-center justify-center" title={title || undefined}>
{date.getDate()}
{items.length > 0 && (
<span
className={cn(
"absolute right-0 top-0 min-w-[14px] rounded-full px-1 text-[9px] font-bold leading-[14px]",
mine > 0 ? "bg-destructive text-destructive-foreground" : "bg-muted-foreground/70 text-background"
)}
>
{mine || items.length}
</span>
)}
</span>
);
},
[activeItemsOn, selectedKey]
);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -86,7 +123,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>
@@ -95,16 +132,34 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
mode="single" mode="single"
selected={selectedDate} selected={selectedDate}
onSelect={handleDateSelect} onSelect={handleDateSelect}
month={month}
onMonthChange={setMonth}
locale={fr} locale={fr}
modifiers={{ modifiers={{
occupied: (date) => isDateOccupied(date) selectedStoreBusy: isSelectedStoreBusy,
otherStoreBusy: isOtherStoreBusy,
}} }}
modifiersClassNames={{ modifiersClassNames={{
occupied: "bg-red-500/20 text-red-700 dark:text-red-300 font-bold hover:bg-red-500/30" selectedStoreBusy: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25",
otherStoreBusy: "bg-muted font-semibold",
}} }}
components={{ DayContent: AgendaDayContent }}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
data-testid="calendar-picker" data-testid="calendar-picker"
/> />
<div className="space-y-1 border-t bg-muted/50 px-3 py-2 text-[10px]">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-destructive" />
<span>Déjà prévu sur un magasin sélectionné</span>
</div>
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-muted-foreground/70" />
<span>Prévu sur d'autres magasins</span>
</div>
{selectedPageIds.length === 0 && (
<p className="text-muted-foreground">Cochez un magasin pour repérer ses jours déjà occupés.</p>
)}
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{value && ( {value && (
@@ -131,7 +186,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>
@@ -147,14 +202,16 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
<ScrollArea className="h-48 min-w-[96px] flex-1"> <ScrollArea className="h-48 min-w-[96px] flex-1">
<div className="p-1.5"> <div className="p-1.5">
<div className="text-[10px] font-semibold mb-1.5 px-1.5 text-muted-foreground">Heures</div> <div className="text-[10px] font-semibold mb-1.5 px-1.5 text-muted-foreground">Heures</div>
{hours.map((hour) => ( {hours.map((hour) => {
const taken = hoursTaken.get(hour) ?? 0;
return (
<Button <Button
key={hour} key={hour}
variant="ghost" variant="ghost"
className={cn( className={cn(
"w-full justify-center mb-0.5 h-9 text-xs", "w-full justify-center mb-0.5 h-9 text-xs",
selectedHour === hour && "bg-primary text-primary-foreground", selectedHour === hour && "bg-primary text-primary-foreground",
isOptimalHour(hour) && selectedHour !== hour && "bg-green-500/20 text-green-700 dark:text-green-300 font-semibold hover:bg-green-500/30" isOptimalHour(hour) && selectedHour !== hour && "bg-success/15 text-success font-semibold hover:bg-success/25"
)} )}
onClick={() => handleTimeChange(hour, selectedMinute)} onClick={() => handleTimeChange(hour, selectedMinute)}
data-testid={`hour-${hour}`} data-testid={`hour-${hour}`}
@@ -163,8 +220,17 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
{isOptimalHour(hour) && selectedHour !== hour && ( {isOptimalHour(hour) && selectedHour !== hour && (
<span className="ml-1 text-[10px]">✨</span> <span className="ml-1 text-[10px]">✨</span>
)} )}
{taken > 0 && (
<span
className="ml-1 rounded-full bg-destructive px-1 text-[9px] font-bold leading-[14px] text-destructive-foreground"
title={`${taken} publication(s) déjà prévue(s) sur vos magasins`}
>
{taken}
</span>
)}
</Button> </Button>
))} );
})}
</div> </div>
</ScrollArea> </ScrollArea>
<ScrollArea className="h-48 min-w-[80px] flex-1 border-l"> <ScrollArea className="h-48 min-w-[80px] flex-1 border-l">
@@ -189,25 +255,26 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
</div> </div>
<div className="p-2 border-t bg-muted/50"> <div className="p-2 border-t bg-muted/50">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div> <div className="w-1.5 h-1.5 rounded-full bg-success"></div>
<span className="text-[9px] font-semibold text-green-600 dark:text-green-400"> <span className="text-[9px] font-semibold text-success">
Heures optimales (8h, 12h-13h, 18h-20h) Heures optimales (8h, 12h-13h, 18h-20h)
</span> </span>
</div> </div>
{hoursTaken.size > 0 && (
<div className="mt-1 flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-destructive"></div>
<span className="text-[9px] font-semibold text-destructive">
Déjà prévu sur vos magasins
</span>
</div>
)}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
{occupiedDates.length > 0 && ( {selectedDate && (
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20"> <DayAgendaPanel date={selectedDate} items={selectedDayItems} selectedPageIds={selectedPageIds} />
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500"></div>
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
Les dates en fond rouge ont déjà des publications programmées
</span>
</div>
</div>
)} )}
</div> </div>
); );
@@ -0,0 +1,97 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { AlertTriangle, CalendarCheck, Clapperboard, Image, Layers, Loader2, Smartphone } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { PlatformIcon } from "@/components/platform-icon";
import { cn } from "@/lib/utils";
import { conflictsFor, groupByPage, type AgendaItem, type AgendaStatus } from "@shared/agenda";
const TYPE_META: Record<string, { label: string; icon: typeof Image }> = {
reel: { label: "Reel", icon: Clapperboard },
story: { label: "Story", icon: Smartphone },
both: { label: "Post + Story", icon: Layers },
feed: { label: "Post", icon: Image },
};
const STATUS_META: Record<AgendaStatus, { label: string; className: string }> = {
scheduled: { label: "Programmé", className: "bg-info/15 text-info" },
rendering: { label: "En montage", className: "bg-warning/15 text-warning" },
published: { label: "Publié", className: "bg-success/15 text-success" },
failed: { label: "Échec", className: "bg-destructive/15 text-destructive" },
};
const time = (item: AgendaItem) => format(new Date(item.scheduledAt), "HH:mm");
interface DayAgendaPanelProps {
date: Date;
items: AgendaItem[];
selectedPageIds: string[];
}
/** Ce qui est déjà prévu le jour choisi, magasin par magasin. */
export function DayAgendaPanel({ date, items, selectedPageIds }: DayAgendaPanelProps) {
const dayLabel = format(date, "EEEE d MMMM", { locale: fr });
const conflicts = conflictsFor(items, selectedPageIds);
const reelConflicts = conflicts.filter((item) => item.postType === "reel");
if (items.length === 0) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground" data-testid="day-agenda-empty">
<CalendarCheck className="h-4 w-4 text-success" />
Rien de prévu le {dayLabel} : la journée est libre sur tous les magasins.
</div>
);
}
return (
<div className="space-y-2" data-testid="day-agenda-panel">
{conflicts.length > 0 && (
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3 text-xs text-warning" data-testid="day-agenda-conflict">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
{(reelConflicts.length > 0 ? reelConflicts : conflicts).map((item) => (
<p key={item.id}>
<span className="font-semibold">{item.pageName}</span> a déjà {reelConflicts.length > 0 ? "un Reel" : "une publication"} à {time(item)} ce jour-là.
</p>
))}
<p className="text-warning/80">Vous pouvez quand même programmer, ou choisir un autre jour.</p>
</div>
</div>
)}
<div className="rounded-lg border">
<div className="border-b bg-muted/40 px-3 py-2 text-xs font-semibold">
Déjà prévu le {dayLabel}
</div>
<div className="divide-y">
{groupByPage(items, selectedPageIds).map(({ page, selected, items: pageItems }) => (
<div key={page.id} className={cn("space-y-1.5 px-3 py-2", selected && "bg-primary/5")}>
<div className="flex items-center gap-2">
<PlatformIcon platform={page.platform} size="sm" className="h-5 w-5 rounded" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{page.pageName}</span>
{selected && <Badge variant="outline" className="h-5 px-1.5 text-[10px]">Sélectionné</Badge>}
</div>
{pageItems.map((item) => {
const type = TYPE_META[item.postType] ?? TYPE_META.feed;
const status = STATUS_META[item.status];
const TypeIcon = type.icon;
return (
<div key={item.id} className="flex items-center gap-2 pl-7 text-xs" data-testid={`day-agenda-item-${item.id}`}>
<span className="w-10 shrink-0 font-mono font-semibold">{time(item)}</span>
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0">{type.label}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.preview}</span>
<span className={cn("inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium", status.className)}>
{item.status === "rendering" && <Loader2 className="h-3 w-3 animate-spin" />}
{status.label}
</span>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt="Selected" alt="Selected"
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className="w-full h-24 object-cover rounded-lg border-2 border-primary" className="w-full h-24 object-cover rounded-lg border-2 border-primary"
/> />
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${ className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
isSelected ? 'border-primary' : 'border-border hover:border-primary/50' isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
+32
View File
@@ -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,207 @@
/**
* Suivi et renouvellement des jetons d'accès.
*
* Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui
* qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas
* son échéance — qui casse la publication. L'UI montre donc l'état réel remonté
* par le contrôle serveur, pas seulement un décompte de jours.
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
valid: { label: 'Connexion active', color: 'text-success', bgColor: 'bg-success/10' },
expiring: { label: 'Expire bientôt', color: 'text-warning-foreground dark:text-warning', bgColor: 'bg-warning/15' },
expired: { label: 'Connexion expirée', color: 'text-destructive', bgColor: 'bg-destructive/10' },
error: { label: 'Contrôle en échec', color: 'text-destructive', bgColor: 'bg-destructive/10' },
};
function statusOf(page: ClientSocialPage): TokenStatus {
return (page.tokenStatus as TokenStatus | null) ?? 'valid';
}
/**
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
*/
export function useConnectFacebook() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/facebook/config'],
});
return () => {
if (config && !config.configured) {
toast({
title: "Facebook n'est pas configuré",
description:
"Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.",
variant: 'destructive',
});
return;
}
window.location.href = '/api/facebook/connect';
};
}
export function ConnectFacebookButton({ className }: { className?: string }) {
const handleClick = useConnectFacebook();
return (
<Button
variant="outline"
className={className}
onClick={handleClick}
data-testid="button-connect-facebook"
>
<Facebook className="w-4 h-4" />
Connecter des pages Facebook
</Button>
);
}
/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */
export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
const { toast } = useToast();
const refreshMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`);
return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: result.renewed
? 'Jeton renouvelé'
: result.status === 'valid'
? 'Jeton valide'
: STATUS_STYLES[result.status].label,
description:
result.error ||
(result.renewed
? `Un nouveau jeton a été généré pour ${page.pageName}.`
: `Aucun renouvellement nécessaire pour ${page.pageName}.`),
variant: result.status === 'valid' ? undefined : 'destructive',
});
},
onError: (error: unknown) => {
toast({
title: 'Contrôle impossible',
description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'),
variant: 'destructive',
});
},
});
return (
<Button
variant="ghost"
size="sm"
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
data-testid={`button-refresh-token-${page.id}`}
>
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
{refreshMutation.isPending ? 'Vérification…' : 'Vérifier'}
</Button>
);
}
/**
* Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance
* quand il y en a une, et l'erreur à corriger le cas échéant.
*/
export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
const status = statusOf(page);
const style = STATUS_STYLES[status];
const isTiktok = page.platform === 'tiktok';
// Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance.
const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
return (
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
<div className="flex items-center gap-2">
{status === 'valid' ? (
<CheckCircle2 className={`w-4 h-4 shrink-0 ${style.color}`} />
) : (
<AlertTriangle className={`w-4 h-4 shrink-0 ${style.color}`} />
)}
<span className={`font-semibold ${style.color}`}>{style.label}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-3.5 h-3.5 shrink-0" />
{expiresAt ? (
<span>
{isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
{format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
</span>
) : page.autoRenew ? (
<span>Sans expiration</span>
) : (
<span>Échéance inconnue</span>
)}
</div>
{page.autoRenew && (
<div className="flex items-center gap-2 text-muted-foreground">
<ShieldCheck className="w-3.5 h-3.5 shrink-0" />
<span>Renouvellement automatique actif</span>
</div>
)}
{page.tokenError && (
<p className={`${style.color} leading-relaxed`} data-testid={`text-token-error-${page.id}`}>
{page.tokenError}
</p>
)}
{page.lastTokenCheck && (
<p className="text-muted-foreground">
Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
</p>
)}
</div>
);
}
/** Bandeau global : les pages qui réclament une reconnexion manuelle. */
export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
const failing = pages.filter((page) => {
const status = statusOf(page);
return status === 'expired' || status === 'error' || status === 'expiring';
});
if (failing.length === 0) return null;
return (
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-semibold text-destructive">
{failing.length === 1
? 'Une page nécessite votre attention'
: `${failing.length} pages nécessitent votre attention`}
</p>
{failing.map((page) => (
<p key={page.id} className="text-muted-foreground">
<strong>{page.pageName}</strong> — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
{page.tokenError ? ` : ${page.tokenError}` : '.'}
</p>
))}
</div>
</div>
);
}
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import Sidebar from "@/components/sidebar";
import { Logo } 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 Social Flow">
<Logo size={30} subtitle={false} />
</Link>
<span className="sr-only">{titleForPath(location)}</span>
</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>
);
}
+189
View File
@@ -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>
</>
);
}
+107
View File
@@ -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>
);
}
+64 -73
View File
@@ -1,92 +1,83 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Users } from "lucide-react"; import { Link } from "wouter";
import { SiFacebook, SiInstagram } from "react-icons/si"; 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() { export default function ManagedPages() {
const { data: pages, isLoading } = useQuery({ const { data: pages, isLoading } = useQuery<ManagedPage[]>({
queryKey: ["/api/pages"], queryKey: ["/api/pages"],
}); });
if (isLoading) {
return ( return (
<div className="bg-card rounded-2xl border border-border/50 p-8 shadow-lg"> <Card>
<div className="skeleton h-8 w-48 mb-6 rounded-lg" /> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div>
<CardTitle>Comptes connectés</CardTitle>
<CardDescription>{pages?.length ?? 0} compte(s) prêt(s) à publier</CardDescription>
</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) => ( {[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-40 rounded-xl" /> <div key={i} className="skeleton h-[72px] rounded-lg" />
))} ))}
</div> </div>
</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}`}>
return ( <PlatformIcon platform={page.platform} />
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg"> <div className="min-w-0 flex-1">
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5"> <p className="truncate text-sm font-medium">{page.pageName}</p>
<div className="flex items-center gap-4"> <p className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg"> {platformLabel(page.platform)}
<Users className="text-white w-6 h-6" /> {page.followersCount ? (
</div> <>
<div> <span>·</span>
<h3 className="text-lg font-semibold text-foreground">Pages gérées</h3> <Users className="h-3 w-3" />
<p className="text-sm text-muted-foreground"> {page.followersCount.toLocaleString("fr-FR")}
{(pages as any[])?.length || 0} page(s) connectée(s) </>
) : null}
</p> </p>
</div> </div>
</div> <Badge variant={page.isActive === "true" ? "success" : "muted"}>
</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" : "bg-[#E4405F]/10"}
`}>
{page.platform === "facebook" ? (
<SiFacebook className="text-[#1877F2] 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"} {page.isActive === "true" ? "Actif" : "Inactif"}
</span> </Badge>
</div> </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" : "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> </div>
</div>
</div>
))
) : ( ) : (
<div className="col-span-full text-center py-16"> <EmptyState
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mx-auto mb-6"> compact
<Users className="w-10 h-10 text-muted-foreground opacity-50" /> icon={Link2}
</div> title="Aucun compte connecté"
<p className="text-muted-foreground text-lg">Aucune page connectée</p> description="Connectez une page Facebook, Instagram ou TikTok pour commencer à publier."
</div> action={
<Button asChild size="sm">
<Link href="/pages">Connecter un compte</Link>
</Button>
}
/>
)} )}
</div> </CardContent>
</div> </Card>
</div>
); );
} }
+4 -1
View File
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
}; };
const handleError = () => { const handleError = () => {
console.warn('Video thumbnail generation failed for:', src); // Cas courant et attendu : la vidéo a été supprimée du disque après
// publication. Les médias créés depuis embarquent une vignette persistée,
// qui évite d'en arriver là.
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
setError(true); setError(true);
video.remove(); video.remove();
}; };
+181 -152
View File
@@ -2,24 +2,68 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, X, Upload, Loader2, ZoomIn, Camera, Trash2 } from "lucide-react";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram } from "react-icons/si";
/** Nombre de vignettes chargées par page. */
const MEDIA_PAGE_SIZE = 15;
export default function MediaUpload() { export default function MediaUpload() {
const { toast } = useToast(); const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<any>(null); const [selectedFile, setSelectedFile] = useState<any>(null);
const [visibleCount, setVisibleCount] = useState(5);
const [zoomImage, setZoomImage] = useState<string | null>(null); const [zoomImage, setZoomImage] = useState<string | null>(null);
const [mediaToDelete, setMediaToDelete] = useState<any>(null);
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const { data: mediaList } = useQuery({ // Pagination côté serveur : la médiathèque entière transitait pour n'afficher
queryKey: ["/api/media"], // que les premières vignettes. La clé reste préfixée par « /api/media » afin
// que les invalidations existantes continuent de la rafraîchir.
const {
data: mediaPages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["/api/media", "pages"],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/media?limit=${MEDIA_PAGE_SIZE}&offset=${pageParam}`, {
credentials: "include",
}); });
if (!response.ok) {
// Même traitement que les autres requêtes : une session expirée renvoie
// vers l'écran de connexion plutôt que sur une médiathèque vide.
if (response.status === 401) handleUnauthorized("/api/media");
throw new Error("Impossible de charger la médiathèque");
}
const items = (await response.json()) as any[];
const total = Number(response.headers.get("X-Total-Count"));
return { items, total: Number.isFinite(total) ? total : items.length };
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((count, page) => count + page.items.length, 0);
return loaded < lastPage.total ? loaded : undefined;
},
});
const mediaList = mediaPages?.pages.flatMap(page => page.items) ?? [];
const totalMedia = mediaPages?.pages[0]?.total ?? 0;
const [uploadingCount, setUploadingCount] = useState(0); const [uploadingCount, setUploadingCount] = useState(0);
@@ -163,8 +207,8 @@ export default function MediaUpload() {
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length)); fetchNextPage();
} }
}, },
{ threshold: 0.1 } { threshold: 0.1 }
@@ -179,56 +223,32 @@ export default function MediaUpload() {
observer.unobserve(loadMoreRef.current); observer.unobserve(loadMoreRef.current);
} }
}; };
}, [mediaList, visibleCount]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const formatPreview = (label: string, size: string, url: string | undefined, aspect: string, width: string) => (
<div className="rounded-lg border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<SiFacebook className="text-facebook" />
<SiInstagram className="text-instagram" />
<span className="text-sm font-medium">{label}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">{size}</span>
</div>
<div className={`mx-auto overflow-hidden rounded-md border bg-muted/40 ${aspect} ${width}`}>
{url ? (
<img src={url} alt={`Aperçu ${label}`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
</div>
)}
</div>
</div>
);
return ( 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 justify-between">
<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">
<Upload className="text-white w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">Médiathèque</h3>
<p className="text-sm text-muted-foreground">Téléchargement et recadrage automatique</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
variant="outline"
className="lg:hidden"
data-testid="button-camera"
>
<Camera className="w-4 h-4" />
</Button>
<Button
onClick={open}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl"
data-testid="button-browse"
>
<CloudUpload className="w-4 h-4 mr-2" />
Parcourir
</Button>
</div>
</div>
</div>
<div className="p-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6"> <div className="space-y-6">
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all
${isDragActive ? "border-primary bg-primary/5 shadow-lg" : "border-border/50 hover:border-primary hover:bg-accent/30"}
${uploadingCount > 0 ? "pointer-events-none opacity-50" : ""}
`}
data-testid="dropzone-upload"
>
<input {...getInputProps()} />
<input <input
ref={cameraInputRef} ref={cameraInputRef}
type="file" type="file"
@@ -237,148 +257,159 @@ export default function MediaUpload() {
onChange={handleCameraCapture} onChange={handleCameraCapture}
className="hidden" className="hidden"
/> />
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-secondary/10 flex items-center justify-center mx-auto mb-6"> <div
{uploadingCount > 0 ? ( {...getRootProps()}
<Loader2 className="w-8 h-8 text-primary animate-spin" /> className={`
) : ( flex cursor-pointer flex-col items-center gap-4 rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors sm:flex-row sm:text-left
<CloudUpload className="w-8 h-8 text-primary" /> ${isDragActive ? "border-primary bg-primary/5" : "hover:border-primary/50"}
)} ${uploadingCount > 0 ? "pointer-events-none opacity-60" : ""}
`}
data-testid="dropzone-upload"
>
<input {...getInputProps()} />
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{uploadingCount > 0 ? <Loader2 className="h-6 w-6 animate-spin" /> : <CloudUpload className="h-6 w-6" />}
</div> </div>
<div className="flex-1">
{uploadingCount > 0 ? ( {uploadingCount > 0 ? (
<> <>
<p className="text-foreground font-semibold text-lg mb-2"> <p className="font-medium">Import en cours…</p>
Téléchargement en cours... <p className="text-sm text-muted-foreground">{uploadingCount} fichier(s) en cours de traitement</p>
</p>
<p className="text-sm text-muted-foreground mb-4">
{uploadingCount} fichier(s) en cours de traitement
</p>
</> </>
) : ( ) : (
<> <>
<p className="text-foreground font-semibold text-lg mb-2"> <p className="font-medium">{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos images et vidéos"}</p>
{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos fichiers"} <p className="text-sm text-muted-foreground">PNG, JPG ou MP4 jusqu'à 50 Mo — recadrées automatiquement pour le fil et les stories.</p>
</p>
<p className="text-sm text-muted-foreground mb-4">ou cliquez pour parcourir</p>
<p className="text-xs text-muted-foreground font-medium">PNG, JPG, MP4 jusqu'à 50MB</p>
</> </>
)} )}
</div> </div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
variant="outline"
className="lg:hidden"
data-testid="button-camera"
>
<Camera className="w-4 h-4" />
Caméra
</Button>
<Button onClick={open} data-testid="button-browse">
<Upload className="w-4 h-4" />
Importer
</Button>
</div>
</div>
<div className="space-y-4"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between"> <div className="lg:col-span-2">
<span>Fichiers téléchargés</span> <div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full"> <h2 className="text-sm font-semibold">Vos médias</h2>
{(mediaList as any[])?.length || 0} média(s) <span className="text-xs text-muted-foreground">{totalMedia} élément(s)</span>
</span> </div>
</h4>
<div className="max-h-[500px] overflow-y-auto"> {mediaList.length === 0 ? (
<div className="grid grid-cols-3 gap-3"> <EmptyState
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => ( icon={ImageIcon}
title="Votre médiathèque est vide"
description="Importez vos premières images ou vidéos pour les utiliser dans vos publications."
/>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 xl:grid-cols-5">
{mediaList.map((media: any) => {
const selected = selectedFile?.id === media.id;
return (
<div <div
key={media.id} key={media.id}
onClick={() => setSelectedFile(media)} onClick={() => setSelectedFile(media)}
className="relative aspect-square rounded-lg overflow-hidden border-2 border-border/50 cursor-pointer hover:border-primary hover:shadow-lg transition-all group" className={`group relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 transition-all ${selected ? "border-primary" : "border-transparent hover:border-primary/40"}`}
data-testid={`media-item-${media.id}`} data-testid={`media-item-${media.id}`}
> >
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
/> />
<div className="absolute top-2 right-2 flex gap-2"> <div className="absolute right-1.5 top-1.5 flex gap-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
{media.type === "image" && ( {media.type === "image" && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setZoomImage(media.originalUrl); setZoomImage(media.originalUrl);
}} }}
className="w-8 h-8 bg-black/70 hover:bg-primary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow backdrop-blur hover:bg-background"
aria-label="Agrandir"
data-testid={`button-zoom-${media.id}`} data-testid={`button-zoom-${media.id}`}
> >
<ZoomIn className="w-4 h-4 text-white" /> <ZoomIn className="h-3.5 w-3.5" />
</button> </button>
)} )}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteMutation.mutate(media.id); setMediaToDelete(media);
}} }}
className="w-8 h-8 bg-black/70 hover:bg-destructive rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-destructive shadow backdrop-blur hover:bg-destructive hover:text-destructive-foreground"
aria-label="Supprimer"
data-testid={`button-delete-${media.id}`} data-testid={`button-delete-${media.id}`}
> >
<X className="w-4 h-4 text-white" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)}
{/* Élément sentinelle pour le scroll infini */} {/* Élément sentinelle pour le scroll infini */}
{(mediaList && visibleCount < (mediaList as any[]).length) ? ( {hasNextPage ? (
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3"> <div ref={loadMoreRef} className="mt-3 flex justify-center py-4">
<Loader2 className="w-5 h-5 text-primary animate-spin" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
</div> </div>
) : null} ) : null}
</div> </div>
<aside>
<div className="space-y-3 rounded-xl border bg-card p-4 shadow-soft lg:sticky lg:top-6">
<div>
<h2 className="text-sm font-semibold">Formats générés</h2>
<p className="text-xs text-muted-foreground">
{selectedFile ? selectedFile.fileName : "Cliquez sur un média pour voir ses recadrages."}
</p>
</div> </div>
{formatPreview("Fil", "1080×1080", selectedFile?.facebookFeedUrl || selectedFile?.instagramFeedUrl, "aspect-square", "w-full max-w-[260px]")}
{formatPreview("Story", "1080×1920", selectedFile?.instagramStoryUrl, "aspect-[9/16]", "w-full max-w-[160px]")}
</div>
</aside>
</div> </div>
<div className="space-y-6"> <AlertDialog open={!!mediaToDelete} onOpenChange={(o) => !o && setMediaToDelete(null)}>
<h4 className="text-sm font-semibold text-foreground">Aperçu et recadrage automatique</h4> <AlertDialogContent>
<AlertDialogHeader>
<div className="space-y-5"> <AlertDialogTitle>Supprimer ce média ?</AlertDialogTitle>
<div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm"> <AlertDialogDescription>
<div className="flex items-center justify-between mb-4"> « {mediaToDelete?.fileName} » sera définitivement retiré de la médiathèque.
<div className="flex items-center gap-3"> </AlertDialogDescription>
<SiFacebook className="text-[#1877F2] text-lg" /> </AlertDialogHeader>
<SiInstagram className="text-[#E4405F] text-lg" /> <AlertDialogFooter>
<span className="text-sm font-semibold text-foreground">Feed</span> <AlertDialogCancel>Annuler</AlertDialogCancel>
</div> <AlertDialogAction
<span className="text-xs text-muted-foreground bg-muted/30 px-2 py-1 rounded-md font-medium">1080×1080</span> className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
</div> onClick={() => {
<div className="aspect-square bg-muted/30 rounded-lg overflow-hidden border border-border/50" style={{ width: '250px' }}> if (mediaToDelete) {
{selectedFile?.facebookFeedUrl || selectedFile?.instagramFeedUrl ? ( deleteMutation.mutate(mediaToDelete.id);
<img if (selectedFile?.id === mediaToDelete.id) setSelectedFile(null);
src={selectedFile.facebookFeedUrl || selectedFile.instagramFeedUrl} }
alt="Feed preview" setMediaToDelete(null);
className="w-full h-full object-cover" }}
/> >
) : ( Supprimer
<div className="w-full h-full flex items-center justify-center"> </AlertDialogAction>
<ImageIcon className="w-12 h-12 text-muted-foreground opacity-50" /> </AlertDialogFooter>
</div> </AlertDialogContent>
)} </AlertDialog>
</div>
</div>
<div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<SiFacebook className="text-[#1877F2] text-lg" />
<SiInstagram className="text-[#E4405F] text-lg" />
<span className="text-sm font-semibold text-foreground">Story</span>
</div>
<span className="text-xs text-muted-foreground bg-muted/30 px-2 py-1 rounded-md font-medium">1080×1920</span>
</div>
<div className="aspect-[9/16] bg-muted/30 rounded-lg overflow-hidden border border-border/50" style={{ width: '200px' }}>
{selectedFile?.instagramStoryUrl ? (
<img
src={selectedFile.instagramStoryUrl}
alt="Story preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-10 h-10 text-muted-foreground opacity-50" />
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<Dialog open={uploadMutation.isPending}> <Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden"> <DialogContent className="sm:max-w-md [&>button]:hidden">
@@ -386,13 +417,11 @@ export default function MediaUpload() {
<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 recadrage.
<br />
Veuillez patienter.
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
+80 -38
View File
@@ -1,30 +1,57 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Clapperboard, Loader2, CheckCircle2, XCircle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, getErrorMessage } from "@/lib/queryClient";
import type { Post } from "@shared/schema"; import type { Post } from "@shared/schema";
/** Labels for each progress stage */ type OngoingReel = Post & { generationStep?: string | null };
function getStageLabel(progress: number, status?: string): string {
if (status === 'pending') return "En attente de traitement..."; /** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
if (progress <= 5) return "Démarrage du traitement…"; const STEP_LABELS: Record<string, string> = {
if (progress <= 15) return "Traitement vidéo (FFmpeg)…"; prepare: "Préparation…",
if (progress <= 65) return "Encodage en cours…"; voice: "Voix et préparation de la vidéo…",
if (progress <= 85) return "Upload vers le cloud…"; render: "Montage : sous-titres, logo, effets…",
if (progress <= 90) return "Liaison du média…"; store: "Enregistrement dans la médiathèque…",
if (progress <= 95) return "Publication en cours…"; publish: "Publication…",
return "Finalisation…"; };
function getStageLabel(post: OngoingReel): string {
if (post.generationStatus === "pending") return "En attente : un autre Reel est en cours…";
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
} }
interface OngoingReelsProps { export default function OngoingReels() {
/** Compact layout for mobile */ const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
compact?: boolean;
}
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
const { data: ongoingPosts = [] } = useQuery<Post[]>({
queryKey: ["/api/reels/ongoing"], queryKey: ["/api/reels/ongoing"],
refetchInterval: 3000, // Poll every 3 seconds // Cette route ne renvoie que les générations en cours : tant qu'elle est
// vide il n'y a pas de barre de progression à animer. On garde une veille
// lente, qui suffit à détecter une génération lancée depuis un autre écran.
refetchInterval: (query) =>
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
});
const { toast } = useToast();
// Un Reel en échec n'a rien à publier : le supprimer retire aussi la notification
const dismissMutation = useMutation({
mutationFn: async (postId: string) => (await apiRequest("DELETE", `/api/reels/${postId}`)).json(),
onSuccess: (_data, postId) => {
queryClient.setQueryData<OngoingReel[]>(["/api/reels/ongoing"], (posts) =>
posts?.filter((p) => p.id !== postId),
);
queryClient.invalidateQueries({ queryKey: ["/api/reels/ongoing"] });
queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"] });
},
onError: (error: unknown) => {
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de supprimer la notification"),
variant: "destructive",
});
},
}); });
// Nothing to show — render nothing (not even a card) // Nothing to show — render nothing (not even a card)
@@ -33,19 +60,19 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
} }
return ( return (
<Card <Card data-testid="card-ongoing-reels">
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : "" <CardHeader className="pb-3">
}`} <CardTitle className="flex items-center gap-2">
data-testid="card-ongoing-reels" {ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
>
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
<Loader2 className="w-5 h-5 animate-spin text-primary" /> <Loader2 className="w-5 h-5 animate-spin text-primary" />
Génération en cours ) : (
<XCircle className="w-5 h-5 text-destructive" />
)}
Reels en cours
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}> <CardContent>
<div className={`space-y-${compact ? "3" : "4"}`}> <div className="space-y-3">
{ongoingPosts.map((post) => { {ongoingPosts.map((post) => {
const progress = post.generationProgress ?? 0; const progress = post.generationProgress ?? 0;
const isFailed = post.generationStatus === "failed"; const isFailed = post.generationStatus === "failed";
@@ -53,25 +80,25 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
return ( return (
<div <div
key={post.id} 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}`} data-testid={`ongoing-reel-${post.id}`}
> >
{/* Icon */} {/* 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"> <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 text-white" /> <Clapperboard className="w-4 h-4" />
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <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"} {post.content || "Reel sans texte"}
</p> </p>
{isFailed ? ( {isFailed ? (
<div className="flex items-center gap-1.5 mt-2 text-destructive"> <div className="flex items-center gap-1.5 mt-2 text-destructive">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs"> <span className="text-xs break-words">
{post.generationError || "Erreur lors du traitement"} Échec : {post.generationError || "erreur lors du traitement"}
</span> </span>
</div> </div>
) : ( ) : (
@@ -84,7 +111,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
</div> </div>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{getStageLabel(progress)} {getStageLabel(post)}
</span> </span>
<span className="text-xs font-medium text-primary"> <span className="text-xs font-medium text-primary">
{progress}% {progress}%
@@ -94,12 +121,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
)} )}
{progress >= 100 && !isFailed && ( {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" /> <CheckCircle2 className="w-4 h-4" />
<span className="text-xs font-medium">Terminé</span> <span className="text-xs font-medium">Terminé</span>
</div> </div>
)} )}
</div> </div>
{isFailed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => dismissMutation.mutate(post.id)}
disabled={dismissMutation.isPending && dismissMutation.variables === post.id}
title="Supprimer"
aria-label="Supprimer ce Reel en échec"
data-testid={`button-dismiss-reel-${post.id}`}
>
<X className="w-4 h-4" />
</Button>
)}
</div> </div>
); );
})} })}
+35
View File
@@ -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>
);
}
+24 -34
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark, Loader2, Video } from 'lucide-react'; import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark, Loader2, Video } from 'lucide-react';
@@ -98,7 +99,7 @@ export function PreviewModal({
<div className="bg-white rounded-lg overflow-hidden border border-gray-200 max-w-lg mx-auto"> <div className="bg-white rounded-lg overflow-hidden border border-gray-200 max-w-lg mx-auto">
{/* Header */} {/* Header */}
<div className="p-3 flex items-center gap-3 border-b border-gray-200"> <div className="p-3 flex items-center gap-3 border-b border-gray-200">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-facebook flex items-center justify-center">
<Facebook className="w-6 h-6 text-white" /> <Facebook className="w-6 h-6 text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -404,7 +405,7 @@ export function PreviewModal({
<Instagram className="w-5 h-5 text-white" /> <Instagram className="w-5 h-5 text-white" />
</div> </div>
) : ( ) : (
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-facebook flex items-center justify-center">
<Facebook className="w-5 h-5 text-white" /> <Facebook className="w-5 h-5 text-white" />
</div> </div>
)} )}
@@ -503,42 +504,31 @@ export function PreviewModal({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Prévisualisation de la publication</DialogTitle> <DialogTitle>Aperçu de la publication</DialogTitle>
<DialogDescription> <DialogDescription>
Visualisez votre publication avant de la publier Vérifiez le rendu sur chaque réseau avant de valider.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
{/* Format selector */} {/* Format selector */}
<div className="flex items-center gap-4"> <Tabs
<label className="text-sm font-medium">Format de prévisualisation:</label> value={previewFormat}
<Select value={previewFormat} onValueChange={(value: PreviewFormat) => { onValueChange={(value) => {
setPreviewFormat(value); setPreviewFormat(value as PreviewFormat);
setCurrentPhotoIndex(0); setCurrentPhotoIndex(0);
}}> }}
<SelectTrigger className="w-64" data-testid="select-preview-format"> >
<SelectValue /> <TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4" data-testid="select-preview-format">
</SelectTrigger> <TabsTrigger value="facebook-feed" data-testid="option-facebook-feed">Facebook · Fil</TabsTrigger>
<SelectContent> <TabsTrigger value="facebook-story" data-testid="option-facebook-story">Facebook · Story</TabsTrigger>
<SelectItem value="facebook-feed" data-testid="option-facebook-feed"> <TabsTrigger value="instagram-feed" data-testid="option-instagram-feed">Instagram · Fil</TabsTrigger>
Feed Facebook (1200x630) <TabsTrigger value="instagram-story" data-testid="option-instagram-story">Instagram · Story</TabsTrigger>
</SelectItem> </TabsList>
<SelectItem value="facebook-story" data-testid="option-facebook-story"> </Tabs>
Story Facebook (1080x1920)
</SelectItem>
<SelectItem value="instagram-feed" data-testid="option-instagram-feed">
Feed Instagram (1080x1080)
</SelectItem>
<SelectItem value="instagram-story" data-testid="option-instagram-story">
Story Instagram (1080x1920)
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Preview */} {/* Preview */}
<div className="border rounded-lg p-6 bg-gray-50"> <div className="border rounded-lg p-4 sm:p-6 bg-muted/40">
{previewFormat === 'facebook-feed' && renderFacebookFeed()} {previewFormat === 'facebook-feed' && renderFacebookFeed()}
{previewFormat === 'facebook-story' && renderFacebookStory()} {previewFormat === 'facebook-story' && renderFacebookStory()}
{previewFormat === 'instagram-feed' && renderInstagramFeed()} {previewFormat === 'instagram-feed' && renderInstagramFeed()}
@@ -546,7 +536,7 @@ export function PreviewModal({
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
{readOnly ? ( {readOnly ? (
<Button <Button
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -561,16 +551,16 @@ export function PreviewModal({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
data-testid="button-cancel-preview" data-testid="button-cancel-preview"
> >
Annuler Modifier
</Button> </Button>
<Button <Button
onClick={onPublish} onClick={onPublish}
disabled={isPublishing} disabled={isPublishing}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90" variant="brand"
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 ? 'Envoi…' : 'Confirmer'}
</Button> </Button>
</> </>
)} )}
-124
View File
@@ -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>
);
}
+89 -75
View File
@@ -1,6 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } 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 { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; 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 { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -28,6 +32,16 @@ import { Trash2 } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
/** Profondeur d'historique du bloc « Publications récentes ». */
const RECENT_WINDOW_DAYS = 90;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfDay(date: Date): Date {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
export default function RecentPublications() { export default function RecentPublications() {
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] }); const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
@@ -36,8 +50,13 @@ export default function RecentPublications() {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Ce bloc n'affiche que les dix dernières publications : inutile de faire
// remonter tout l'historique pour cela.
const since = new Date();
since.setDate(since.getDate() - RECENT_WINDOW_DAYS);
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }],
}); });
// Filter and sort attempted posts (scheduled in the past) // Filter and sort attempted posts (scheduled in the past)
@@ -132,111 +151,106 @@ export default function RecentPublications() {
}; };
return ( return (
<Card className="rounded-2xl border-border/50 shadow-lg" data-testid="card-recent-publications"> <Card data-testid="card-recent-publications">
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Historique des publications</CardTitle> <div>
<CardDescription> <CardTitle>Dernières publications</CardTitle>
Vos 10 dernières publications sur les réseaux sociaux <CardDescription>Les 10 publications les plus récentes</CardDescription>
</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> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-2">
{[1, 2, 3].map((i) => ( {[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> </div>
) : recentPublished.length === 0 ? ( ) : recentPublished.length === 0 ? (
<div className="text-center py-12"> <EmptyState
<Clock className="w-12 h-12 text-muted-foreground mx-auto mb-3" /> compact
<p className="text-muted-foreground">Aucune publication pour le moment</p> icon={Clock}
</div> 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) => ( {recentPublished.map((scheduledPost) => (
<div <li
key={scheduledPost.id} 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}`} data-testid={`publication-${scheduledPost.id}`}
> >
{/* Platform icon */} <PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
? 'bg-blue-500' <div className="min-w-0 flex-1">
: 'bg-gradient-to-br from-purple-500 to-pink-500' <div className="flex items-center gap-2">
}`}> <p className="truncate text-sm font-medium" data-testid={`text-page-name-${scheduledPost.id}`}>
{scheduledPost.page?.platform === 'facebook' ? ( {scheduledPost.page?.pageName || "Page inconnue"}
<Facebook className="w-5 h-5 text-white" /> </p>
{scheduledPost.error ? (
<Badge variant="danger" className="gap-1">
<XCircle className="h-3 w-3" /> Échec
</Badge>
) : ( ) : (
<Instagram className="w-5 h-5 text-white" /> <Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Publié
</Badge>
)} )}
</div> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{/* Content */} {scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
<div className="flex-1 min-w-0"> </p>
<div className="flex items-start justify-between gap-2"> <p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex-1 min-w-0"> {getPostTypeIcon(scheduledPost.postType)}
<h4 className="font-medium text-sm truncate" data-testid={`text-page-name-${scheduledPost.id}`}> {scheduledPost.postType === "feed"
{scheduledPost.page?.pageName || 'Page inconnue'} ? "Feed"
</h4> : scheduledPost.postType === "story"
<p className="text-xs text-muted-foreground mt-1"> ? "Story"
{scheduledPost.post?.content ? ( : scheduledPost.postType === "reel"
<span className="line-clamp-2">{scheduledPost.post.content}</span> ? "Reel"
) : ( : "Feed & Story"}
<span className="italic">Aucun texte</span> <span>·</span>
)} {format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex shrink-0 items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => handlePreviewPost(scheduledPost)} onClick={() => handlePreviewPost(scheduledPost)}
className="h-8 w-8 p-0" className="h-8 w-8 text-muted-foreground"
data-testid={`button-preview-post-${scheduledPost.id}`} data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser" title="Prévisualiser"
aria-label="Prévisualiser"
> >
<Eye className="w-4 h-4 text-muted-foreground hover:text-foreground" /> <Eye className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => handleDeleteClick(scheduledPost.postId)} onClick={() => handleDeleteClick(scheduledPost.postId)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive transition-colors" className="h-8 w-8 text-muted-foreground hover:text-destructive"
data-testid={`button-delete-post-${scheduledPost.id}`} data-testid={`button-delete-post-${scheduledPost.id}`}
title="Supprimer" title="Supprimer"
aria-label="Supprimer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="h-4 w-4" />
</Button> </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>
</div>
</div> </div>
</li>
))} ))}
</div> </ul>
)} )}
</CardContent> </CardContent>
@@ -254,7 +268,7 @@ export default function RecentPublications() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle> <AlertDialogTitle>Supprimer cette publication ?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Cette action est irréversible. Cela supprimera définitivement cette publication de votre historique et des statistiques associées. Cette action est irréversible. Cela supprimera définitivement cette publication de votre historique et des statistiques associées.
</AlertDialogDescription> </AlertDialogDescription>
@@ -0,0 +1,63 @@
import { CAPTION_STYLE_OPTIONS, type CaptionStyle } from "@shared/captions";
import { cn } from "@/lib/utils";
/** Aperçu miniature de chaque style de sous-titres. */
const SAMPLE: Record<CaptionStyle, React.ReactNode> = {
bold: (
<span className="font-black uppercase tracking-tight" style={{ WebkitTextStroke: "1px #000", paintOrder: "stroke fill" }}>
<span className="text-white">Nos </span>
<span className="text-yellow-300">promos</span>
</span>
),
neon: (
<span className="font-extrabold">
<span className="rounded px-1 bg-black/50 text-white">Nos</span>{" "}
<span className="rounded px-1 bg-violet-600 text-white">promos</span>
</span>
),
minimal: (
<span className="font-bold">
<span className="text-white">Nos </span>
<span className="text-white/45">promos</span>
</span>
),
};
export function CaptionStylePicker({
value,
onChange,
compact = false,
}: {
value: CaptionStyle;
onChange: (style: CaptionStyle) => void;
compact?: boolean;
}) {
return (
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Style des sous-titres">
{CAPTION_STYLE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={value === option.id}
title={option.description}
onClick={() => onChange(option.id)}
className={cn(
"rounded-lg border p-2 text-left transition-colors",
value === option.id ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-primary/50",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md bg-gradient-to-br from-slate-700 to-slate-900",
compact ? "h-9 text-[11px]" : "h-12 text-sm",
)}
>
{SAMPLE[option.id]}
</div>
<p className={cn("mt-1 font-medium", compact ? "text-[11px]" : "text-xs")}>{option.label}</p>
</button>
))}
</div>
);
}
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Cpu, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
interface QwenConfig {
url: string;
hasApiKey: boolean;
}
interface QwenTestResult {
ok: boolean;
error?: string;
model?: string;
device?: string;
voices?: number;
}
/**
* Réglages du service Qwen3-TTS : voix locale gratuite, idéalement sur un
* serveur avec carte graphique (ex. Unraid). C'est le service FFmpeg qui
* l'appelle : le test vérifie donc la connexion depuis celui-ci.
*/
export function QwenSettingsCard() {
const { toast } = useToast();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [test, setTest] = useState<QwenTestResult | null>(null);
const { data: config } = useQuery<QwenConfig>({ queryKey: ["/api/settings/qwen"] });
useEffect(() => {
if (config) setUrl(config.url);
}, [config]);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["/api/settings/qwen"] });
// Le choix du moteur « Qwen » dépend de la disponibilité du service
queryClient.invalidateQueries({ queryKey: ["/api/reels/voices"] });
};
const save = useMutation({
mutationFn: () => apiRequest("POST", "/api/settings/qwen", { url, apiKey }),
onSuccess: () => {
refresh();
setApiKey("");
toast({ title: "Configuration sauvegardée", description: "Service Qwen TTS enregistré" });
},
onError: (error) =>
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de sauvegarder la configuration Qwen"),
variant: "destructive",
}),
});
const remove = useMutation({
mutationFn: () => apiRequest("DELETE", "/api/settings/qwen"),
onSuccess: () => {
refresh();
setUrl("");
setApiKey("");
setTest(null);
toast({ title: "Configuration supprimée", description: "Service Qwen TTS retiré" });
},
});
const runTest = useMutation({
mutationFn: async () => {
const response = await apiRequest("POST", "/api/settings/qwen/test", { url, apiKey });
return (await response.json()) as QwenTestResult;
},
onSuccess: setTest,
onError: (error) => setTest({ ok: false, error: getErrorMessage(error, "Test impossible") }),
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="w-5 h-5" />
Qwen TTS (voix locale)
</CardTitle>
<CardDescription>
Voix française gratuite et expressive, générée par votre propre serveur (idéalement avec carte
graphique NVIDIA, ex. Unraid). Voir qwen-tts/docker-compose.gpu.yml.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="qwenTtsUrl">Adresse du service</Label>
<Input
id="qwenTtsUrl"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://192.168.1.20:8001"
data-testid="input-qwen-url"
/>
</div>
<div className="space-y-2">
<Label htmlFor="qwenTtsApiKey">Clé d'accès (API_KEY du service)</Label>
<Input
id="qwenTtsApiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={config?.hasApiKey ? "••••••••••••••••" : "Clé définie sur le serveur Qwen"}
data-testid="input-qwen-api-key"
/>
{config?.hasApiKey && (
<p className="text-xs text-success">✓ Clé enregistrée (laisser vide pour la garder)</p>
)}
</div>
{test && (
<p className={`text-sm ${test.ok ? "text-success" : "text-destructive"}`}>
{test.ok
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
: `✗ ${test.error}`}
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
onClick={() => runTest.mutate()}
disabled={runTest.isPending || (!url && !config?.url)}
data-testid="button-test-qwen"
>
{runTest.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Tester la connexion
</Button>
<Button
className="flex-1"
onClick={() => save.mutate()}
disabled={save.isPending || !url}
data-testid="button-save-qwen"
>
{save.isPending ? "Enregistrement..." : "Enregistrer Qwen TTS"}
</Button>
{config?.url && (
<Button variant="outline" onClick={() => remove.mutate()} disabled={remove.isPending}>
Retirer
</Button>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useState } from "react";
import { Player } from "@remotion/player";
import { Loader2, Info } from "lucide-react";
import {
REEL_FPS,
REEL_HEIGHT,
REEL_WIDTH,
VOICE_DELAY,
cleanCaptionText,
computeReelTiming,
offsetWords,
spreadWords,
type CaptionStyle,
type TimedWord,
} from "@shared/captions";
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
import type { VoicePreviewResult } from "./voice-picker";
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
const WORDS_PER_SECOND = 2.6;
interface CommonProps {
text: string;
showCaptions: boolean;
captionStyle: CaptionStyle;
ttsEnabled: boolean;
/** Voix générée pour ce texte et ces réglages, sinon null (minutage estimé). */
voice: VoicePreviewResult | null;
musicUrl?: string;
musicVolume: number;
logoUrl?: string | null;
/** Petit logo pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */
showWatermark: boolean;
storeName?: string;
endingEffect: boolean;
/** Sous-titres SRT : remplacent `text`, minutés par le fichier. */
srtCues?: SrtCue[] | null;
}
type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string };
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
function useVideoDuration(url: string | undefined): number | null {
const [duration, setDuration] = useState<number | null>(null);
useEffect(() => {
setDuration(null);
if (!url) return;
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => setDuration(Number.isFinite(video.duration) ? video.duration : 15);
video.onerror = () => setDuration(15);
video.src = url;
return () => {
video.onloadedmetadata = null;
video.onerror = null;
video.src = "";
};
}, [url]);
return duration;
}
function estimatedVoiceDuration(text: string): number {
const words = cleanCaptionText(text).split(" ").filter(Boolean).length;
return words ? words / WORDS_PER_SECOND + 0.4 : 0;
}
/**
* Aperçu du Reel dans le navigateur, avec les mêmes compositions Remotion que
* le rendu final : sous-titres, logo, effet de fin et durée identiques.
* Sans voix générée, le minutage des mots est estimé.
*/
export function ReelPreview(props: ReelPreviewProps) {
const videoDuration = useVideoDuration(props.videoUrl);
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
props;
const logoUrl = props.logoUrl ?? undefined;
const storeName = endingEffect ? props.storeName || undefined : undefined;
const srtCues = props.srtCues?.length ? props.srtCues : null;
const cleanText = cleanCaptionText(text);
const voiceDuration = srtCues
? ttsEnabled ? srtEnd(srtCues) : 0
: ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0;
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
const composition = useMemo(() => {
if (videoDuration == null) return null;
const timing = computeReelTiming({
videoDuration,
voiceDuration: voiceDuration || undefined,
hasOutro: Boolean(logoUrl),
endingEffect,
voiceDelay: srtCues ? 0 : VOICE_DELAY,
});
let words: TimedWord[] = [];
if (srtCues) {
// La voix de l'aperçu n'est pas générée sous-titre par sous-titre : minutage du fichier
if (showCaptions) words = srtWords(srtCues);
} else if (showCaptions && cleanText) {
if (voice) words = offsetWords(voice.words, VOICE_DELAY);
else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration);
else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5);
}
const inputProps: ReelVideoProps = {
videoUrl: props.videoUrl,
videoDuration,
totalDuration: timing.total,
words,
captionStyle,
logoUrl,
showWatermark,
storeName,
logoStart: timing.logoStart,
fadeStart: timing.fadeStart,
fadeDuration: timing.fadeDuration,
voiceUrl: srtCues ? undefined : voice?.audioUrl,
voiceDelay: VOICE_DELAY,
musicUrl,
musicVolume,
};
return { component: ReelVideo, inputProps, total: timing.total };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
props.videoUrl,
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
]);
if (!composition) {
return (
<div className="aspect-[9/16] w-full rounded-lg bg-black flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-white/70" />
</div>
);
}
return (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg bg-black">
<Player
// Nouvelle instance quand la durée change : le lecteur repart du début
key={composition.total}
component={composition.component as React.ComponentType<Record<string, unknown>>}
inputProps={composition.inputProps as unknown as Record<string, unknown>}
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
fps={REEL_FPS}
compositionWidth={REEL_WIDTH}
compositionHeight={REEL_HEIGHT}
style={{ width: "100%", aspectRatio: "9 / 16" }}
controls
loop
acknowledgeRemotionLicense
/>
</div>
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{srtCues
? `Minutage du fichier SRT (la voix sera posée sur chaque sous-titre au rendu) · ${composition.total.toFixed(1)} s`
: estimated
? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix."
: `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}
</p>
</div>
);
}
@@ -0,0 +1,89 @@
import { useRef } from "react";
import { FileText, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { formatSrtTime, parseSrt, srtEnd, type SrtCue } from "@shared/srt";
const MAX_SRT_BYTES = 512 * 1024;
export interface SrtFile {
name: string;
cues: SrtCue[];
}
interface SrtUploadProps {
value: SrtFile | null;
onChange: (value: SrtFile | null) => void;
}
/**
* Import d'un fichier de sous-titres SRT. Tant qu'un fichier est chargé, il
* remplace le texte libre : la voix lit chaque sous-titre à son instant.
*/
export function SrtUpload({ value, onChange }: SrtUploadProps) {
const { toast } = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = async (file: File | undefined) => {
if (!file) return;
try {
if (file.size > MAX_SRT_BYTES) throw new Error("Fichier SRT trop volumineux (512 Ko au plus)");
const cues = parseSrt(await file.text());
onChange({ name: file.name, cues });
toast({ title: "Sous-titres importés", description: `${cues.length} sous-titre(s) · ${formatSrtTime(srtEnd(cues))}` });
} catch (error) {
toast({
title: "Fichier SRT invalide",
description: error instanceof Error ? error.message : "Lecture impossible",
variant: "destructive",
});
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept=".srt,application/x-subrip,text/plain"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{value ? (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 text-sm font-medium">
<FileText className="w-4 h-4 shrink-0 text-primary" />
<span className="truncate">{value.name}</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
<X className="w-4 h-4 mr-1" />
Retirer
</Button>
</div>
<p className="text-xs text-muted-foreground">
{value.cues.length} sous-titre(s) · jusqu'à {formatSrtTime(srtEnd(value.cues))}. La voix lira chaque
sous-titre à son instant et accélérera si besoin pour tenir dans sa durée.
</p>
<ul className="max-h-40 overflow-y-auto space-y-1 text-xs">
{value.cues.map((cue, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 font-mono text-muted-foreground">
{formatSrtTime(cue.start)}–{formatSrtTime(cue.end)}
</span>
<span>{cue.text}</span>
</li>
))}
</ul>
</div>
) : (
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
<Upload className="w-4 h-4" />
Importer un fichier SRT
</Button>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { Switch } from "@/components/ui/switch";
interface StoryToggleProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
/**
* Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a
* pas de stories : l'option ne le concerne pas.
*/
export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
@@ -0,0 +1,236 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Play, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage } from "@/lib/queryClient";
import {
DEFAULT_VOICE,
TTS_STYLE_OPTIONS,
voicesFor,
type TtsEngine,
type TtsStyle,
type VoiceOption,
} from "@shared/voices";
import type { TimedWord } from "@shared/captions";
export interface VoiceSettings {
engine: TtsEngine;
voice: string;
style: TtsStyle;
}
/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */
export interface VoicePreviewResult {
audioUrl: string;
duration: number;
words: TimedWord[];
/** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */
text: string;
settings: VoiceSettings;
}
export function isVoicePreviewCurrent(
preview: VoicePreviewResult | null,
text: string,
settings: VoiceSettings,
): preview is VoicePreviewResult {
return (
!!preview &&
preview.text === text.trim() &&
preview.settings.engine === settings.engine &&
preview.settings.voice === settings.voice &&
preview.settings.style === settings.style
);
}
interface VoicePickerProps {
value: VoiceSettings;
onChange: (value: VoiceSettings) => void;
/** Texte lu par le bouton « Tester la voix ». */
sampleText?: string;
compact?: boolean;
/** Appelé avec la voix générée (audio et minutage des mots). */
onPreview?: (result: VoicePreviewResult) => void;
}
const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
/**
* Choix du moteur, de la voix et du ton, avec écoute de l'aperçu.
* Partagé par les pages Reel (vidéo et images, bureau et mobile).
*/
export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => () => audioRef.current?.pause(), []);
// Voix Qwen réellement installées (voix clonées comprises) ; moteur grisé si le service local ne répond pas
const { data: qwenCatalog } = useQuery<{ qwenAvailable: boolean; qwen: VoiceOption[] }>({
queryKey: ["/api/reels/voices"],
staleTime: 5 * 60_000,
});
const qwenAvailable = qwenCatalog?.qwenAvailable ?? false;
const voices = value.engine === "qwen" && qwenCatalog?.qwen.length ? qwenCatalog.qwen : voicesFor(value.engine);
const groups = [
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
];
const setEngine = (engine: TtsEngine) => {
if (engine !== value.engine) onChange({ ...value, engine, voice: DEFAULT_VOICE[engine] });
};
const stop = () => {
audioRef.current?.pause();
setPlaying(false);
};
const preview = async () => {
if (playing) return stop();
setLoading(true);
try {
const text = sampleText?.trim() || FALLBACK_SAMPLE;
const response = await apiRequest("POST", "/api/reels/tts-preview", {
text,
ttsEngine: value.engine,
ttsVoice: value.voice,
ttsStyle: value.style,
});
const data = await response.json();
for (const warning of data.warnings ?? []) {
toast({ title: "Voix de secours utilisée", description: warning });
}
const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`;
if (sampleText?.trim()) {
onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value });
}
const audio = new Audio(audioUrl);
audioRef.current?.pause();
audioRef.current = audio;
audio.onended = () => setPlaying(false);
await audio.play();
setPlaying(true);
} catch (error) {
toast({
title: "Impossible de tester la voix",
description: getErrorMessage(error, "La voix n'a pas pu être générée."),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const labelClass = compact ? "text-xs font-medium" : "text-sm font-medium";
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "qwen" ? "default" : "outline"}
disabled={!qwenAvailable && value.engine !== "qwen"}
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non configuré ou injoignable (Paramètres → Qwen TTS)"}
onClick={() => setEngine("qwen")}
>
Qwen (locale)
</Button>
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "gemini" ? "default" : "outline"}
onClick={() => setEngine("gemini")}
>
Gemini (naturelle)
</Button>
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "edge" ? "default" : "outline"}
onClick={() => setEngine("edge")}
>
Edge (gratuite)
</Button>
</div>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Voix</Label>
<Select value={value.voice} onValueChange={(voice) => onChange({ ...value, voice })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue placeholder="Choisir une voix" />
</SelectTrigger>
<SelectContent className="max-h-72">
{groups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Ton</Label>
<Select value={value.style} onValueChange={(style) => onChange({ ...value, style: style as TtsStyle })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TTS_STYLE_OPTIONS.map((style) => (
<SelectItem key={style.id} value={style.id}>
{style.label} <span className="text-muted-foreground">— {style.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className={compact ? "w-full h-8 text-xs" : "w-full"}
disabled={loading}
onClick={(e) => {
e.stopPropagation();
preview();
}}
>
{loading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : playing ? (
<Square className="w-3 h-3" />
) : (
<Play className="w-3 h-3" />
)}
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
</Button>
</div>
);
}
@@ -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} />;
};
}
+140 -266
View File
@@ -1,304 +1,178 @@
import { Home, PlusCircle, 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 { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query"; import { ChevronsLeft, ChevronsRight, LogOut, Plus } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { Logo, LogoMark } from "@/components/brand/logo";
import { useToast } from "@/hooks/use-toast"; import { ThemeToggle } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; 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 { function readCollapsed(): boolean {
onLinkClick?: () => void; 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-[7px] 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 [location] = useLocation();
const [, setLocation] = useLocation(); const { session, isAdmin } = useSession();
const { toast } = useToast(); const logout = useLogout();
const [isCollapsed, setIsCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(readCollapsed);
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
});
useEffect(() => { useEffect(() => {
localStorage.setItem('sidebar-collapsed', String(isCollapsed)); try {
}, [isCollapsed]); localStorage.setItem("sidebar-collapsed", String(collapsed));
} catch {
useEffect(() => { /* stockage indisponible : on ignore */
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();
} }
// Navigate after a small delay to ensure state update completes }, [collapsed]);
setTimeout(() => {
setLocation(href);
}, 10);
};
const logoutMutation = useMutation({ const groups = visibleGroups(isAdmin);
mutationFn: async () => { const initials = session?.username.substring(0, 2).toUpperCase() ?? "?";
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: "New" },
{ icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" },
{ 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: "New" },
];
const statsItems = [
{ icon: Clock, label: "Historique", href: "/history" },
];
return ( return (
<aside className={`bg-sidebar border-r border-sidebar-border flex flex-col h-screen transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-72'}`}> <aside
{/* Logo & Brand */} className={cn(
<div className="p-6 border-b border-sidebar-border"> "sticky top-0 hidden h-screen shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 lg:flex",
<div className="flex items-center gap-3"> collapsed ? "w-[76px]" : "w-64",
<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>
</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;
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={cn("flex h-16 items-center border-b border-sidebar-border", collapsed ? "justify-center" : "px-5")}>
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" /> <Link href="/" aria-label="Accueil Social Flow">
)} {collapsed ? <LogoMark size={34} /> : <Logo size={34} />}
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} /> </Link>
{!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> </div>
)}
</li>
);
})}
</ul>
{/* Stats Section */} <div className={cn("pt-4", collapsed ? "px-3" : "px-4")}>
<div className="mt-8 pt-6 border-t border-sidebar-border"> <Button asChild variant="brand" className={cn("w-full", collapsed && "px-0")} data-testid="button-sidebar-create">
{!isCollapsed && ( <Link href="/new" aria-label="Créer une publication">
<p className="text-xs font-semibold text-muted-foreground mb-3 px-4 uppercase tracking-wider">Statistiques</p> <Plus className="h-4 w-4" />
)} {!collapsed && "Créer"}
<ul className="space-y-1"> </Link>
{statsItems.map((item) => { </Button>
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> </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-4")}>
{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> </li>
); ))}
})}
</ul> </ul>
</div> </div>
))}
</nav> </nav>
{/* Admin & User Section */} <div className={cn("border-t border-sidebar-border p-3", collapsed && "flex flex-col items-center gap-2")}>
<div className="p-4 border-t border-sidebar-border space-y-2"> {session && (
{isAdmin && ( <DropdownMenu>
<> <DropdownMenuTrigger asChild>
{/* Admin section toggle */}
<button <button
onClick={() => !isCollapsed && setAdminExpanded(v => !v)} className={cn(
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' : ''}`} "flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-sidebar-accent",
> collapsed && "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 && ( data-testid="button-user-menu"
<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 <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> </div>
)} )}
</button> </button>
</DropdownMenuTrigger>
{/* Admin links — collapsible */} <DropdownMenuContent side={collapsed ? "right" : "top"} align="start" className="w-64">
{(adminExpanded || isCollapsed) && ( <DropdownMenuLabel className="font-normal">
<div className={`space-y-0.5 ${!isCollapsed ? 'pl-2' : ''}`}> <p className="text-sm font-medium">{session.username}</p>
{[ <p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
{ href: "/pages", icon: Users, label: "Pages gérées", testId: "link-pages-gérées" }, </DropdownMenuLabel>
{ href: "/ai", icon: Bot, label: "Assistant IA", testId: "link-assistant-ia" }, <DropdownMenuSeparator />
{ href: "/users", icon: UserCog, label: "Utilisateurs", testId: "link-users" }, <div className="px-2 py-1.5">
{ href: "/sql", icon: Database,label: "SQL", testId: "link-sql" }, <p className="mb-1.5 text-xs text-muted-foreground">Apparence</p>
{ href: "/audio-admin", icon: Music, label: "Bibliothèque Audio", testId: "link-audio-admin" }, <ThemeToggle />
{ 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> </div>
)} <DropdownMenuSeparator />
</a> <DropdownMenuItem
))} onClick={() => logout.mutate()}
</div> disabled={logout.isPending}
)} className="text-destructive focus:text-destructive"
</>
)}
<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" data-testid="button-logout"
> >
<LogOut className={`w-5 h-5 ${isCollapsed ? '' : 'mr-3'}`} /> <LogOut className="h-4 w-4" />
{!isCollapsed && (logoutMutation.isPending ? "Déconnexion..." : "Déconnexion")} {logout.isPending ? "Déconnexion…" : "Se déconnecter"}
</Button> </DropdownMenuItem>
{isCollapsed && ( </DropdownMenuContent>
<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"> </DropdownMenu>
Déconnexion
</div>
)}
</div>
{/* User Profile Card */}
{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>
)} )}
{/* Toggle Button (Desktop only) */}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setCollapsed((c) => !c)}
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" 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" 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> </button>
</div> </div>
</aside> </aside>
+61 -56
View File
@@ -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 { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { cn } from "@/lib/utils";
interface StatsResponse { interface StatsResponse {
scheduledPosts: number; scheduledPosts: number;
@@ -20,46 +22,49 @@ export default function StatsCards() {
const statCards = [ const statCards = [
{ {
icon: CalendarCheck, icon: CalendarCheck,
iconColor: "from-blue-500 to-blue-600", tone: "bg-primary/10 text-primary",
title: stats?.scheduledPosts ?? 0, value: stats?.scheduledPosts ?? 0,
subtitle: "Publications planifiées", label: "Publications planifiées",
change: stats?.scheduledPostsChange, change: stats?.scheduledPostsChange,
trending: stats?.scheduledPostsTrending, trending: stats?.scheduledPostsTrending,
changeLabel: "vs mois dernier", changeLabel: "vs mois dernier",
href: "/calendar",
}, },
{ {
icon: Users, icon: Link2,
iconColor: "from-purple-500 to-purple-600", tone: "bg-info/10 text-info",
title: stats?.connectedPages ?? 0, value: stats?.connectedPages ?? 0,
subtitle: "Pages connectées", label: "Comptes connectés",
info: "Facebook • Instagram", info: "Facebook · Instagram · TikTok",
href: "/pages",
}, },
{ {
icon: Bot, icon: Sparkles,
iconColor: "from-green-500 to-green-600", tone: "bg-brand-accent/15 text-brand-accent",
title: stats?.aiTextsGenerated ?? 0, value: stats?.aiTextsGenerated ?? 0,
subtitle: "Textes générés par IA", label: "Textes générés par l'IA",
change: stats?.aiTextsChange, change: stats?.aiTextsChange,
trending: stats?.aiTextsTrending, trending: stats?.aiTextsTrending,
changeLabel: "vs hier", changeLabel: "vs hier",
}, },
{ {
icon: Images, icon: Images,
iconColor: "from-cyan-500 to-cyan-600", tone: "bg-success/10 text-success",
title: stats?.mediaStored ?? 0, value: stats?.mediaStored ?? 0,
subtitle: "Médias stockés", label: "Médias stockés",
info: "Images • Vidéos", info: "Images · Vidéos",
href: "/media",
}, },
]; ];
if (isLoading) { if (isLoading) {
return ( 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) => ( {[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-6 shadow-sm"> <div key={i} className="rounded-xl border bg-card p-4 shadow-soft sm:p-5">
<div className="skeleton h-12 w-12 rounded-xl mb-4" /> <div className="skeleton mb-4 h-10 w-10 rounded-lg" />
<div className="skeleton h-10 w-20 mb-2" /> <div className="skeleton mb-2 h-7 w-16 rounded" />
<div className="skeleton h-4 w-36" /> <div className="skeleton h-4 w-28 rounded" />
</div> </div>
))} ))}
</div> </div>
@@ -67,45 +72,45 @@ export default function StatsCards() {
} }
return ( 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) => { {statCards.map((stat, index) => {
const Icon = stat.icon; const Icon = stat.icon;
const TrendIcon = stat.trending === "up" ? TrendingUp : TrendingDown; const TrendIcon = stat.trending === "up" ? TrendingUp : TrendingDown;
const body = (
return ( <>
<div <div className={cn("mb-4 flex h-10 w-10 items-center justify-center rounded-lg", stat.tone)}>
key={index} <Icon className="h-5 w-5" />
className="group bg-card rounded-2xl border border-border p-6 hover:border-primary/30 transition-all cursor-pointer card-hover shadow-sm" </div>
data-testid={`stat-card-${index}`} <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",
)}
> >
<div className="flex items-start justify-between mb-6"> <TrendIcon className="h-3 w-3" />
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${stat.iconColor} flex items-center justify-center shadow-lg`}> {stat.change}
<Icon className="text-white w-7 h-7" /> </span>
<span className="text-muted-foreground">{stat.changeLabel}</span>
</div> </div>
</div> ) : stat.info ? (
<p className="mt-3 hidden text-xs text-muted-foreground sm:block">{stat.info}</p>
<div className="space-y-1"> ) : null}
<h3 className="text-4xl font-bold text-foreground" data-testid={`stat-value-${index}`}> </>
{stat.title} );
</h3> const cls = "block rounded-xl border bg-card p-4 shadow-soft sm:p-5";
<p className="text-sm text-muted-foreground font-medium">{stat.subtitle}</p> return stat.href ? (
</div> <Link key={index} href={stat.href} className={cn(cls, "card-hover")} data-testid={`stat-card-${index}`}>
{body}
{stat.change && ( </Link>
<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"}`}> <div key={index} className={cls} data-testid={`stat-card-${index}`}>
<TrendIcon className="w-3 h-3" /> {body}
<span className="text-xs font-semibold">{stat.change}</span>
</div>
<span className="text-xs 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>
)}
</div> </div>
); );
})} })}
+150
View File
@@ -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>
);
}
+51
View File
@@ -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 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-1.5 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>
);
}
-52
View File
@@ -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>
);
}
+6 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
@@ -15,6 +15,11 @@ const badgeVariants = cva(
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground", 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: { defaultVariants: {
+9 -7
View File
@@ -5,25 +5,27 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { 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: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: 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: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-11 px-4 py-2", default: "h-10 px-4 py-2",
sm: "h-11 rounded-md px-3", sm: "h-9 rounded-md px-3",
lg: "h-12 rounded-md px-8", lg: "h-11 rounded-lg px-6 text-[15px]",
icon: "h-11 w-11", icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
+5 -5
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-xl border bg-card text-card-foreground shadow-soft",
className className
)} )}
{...props} {...props}
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} 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} {...props}
/> />
)) ))
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-base font-semibold leading-tight tracking-tight",
className className
)} )}
{...props} {...props}
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ 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" CardContent.displayName = "CardContent"
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} 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} {...props}
/> />
)) ))
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+2 -2
View File
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return ( return (
<textarea <textarea
className={cn( 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 className
)} )}
ref={ref} ref={ref}
@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { addDays, endOfMonth, startOfMonth } from "date-fns";
import { groupByDay, type AgendaItem } from "@shared/agenda";
/**
* Publications prévues autour du mois affiché, rangées par jour.
*
* La clé commence par `/api/scheduled-posts` : toute invalidation des
* publications planifiées (création, suppression, fin de montage) la rafraîchit.
*/
export function usePublicationAgenda(month: Date) {
// Le calendrier montre aussi les jours des mois voisins
const startDate = addDays(startOfMonth(month), -7).toISOString();
const endDate = addDays(endOfMonth(month), 7).toISOString();
const { data: items = [], isLoading } = useQuery<AgendaItem[]>({
queryKey: ["/api/scheduled-posts", "agenda", { startDate, endDate }],
refetchInterval: 30000,
});
const itemsByDay = useMemo(() => groupByDay(items), [items]);
return { items, itemsByDay, isLoading };
}
+36
View File
@@ -0,0 +1,36 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export interface Session {
id: string;
username: string;
role: string;
}
/** Session courante (mise en cache par react-query, partagée par toute l'app). */
export function useSession() {
const query = useQuery<Session>({
queryKey: ["/api/auth/session"],
retry: false,
});
return { ...query, session: query.data, isAdmin: query.data?.role === "admin" };
}
export function useLogout() {
const [, setLocation] = useLocation();
const { toast } = useToast();
return 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: "À bientôt !" });
},
});
}
-72
View File
@@ -1,72 +0,0 @@
import { useState, useEffect } from 'react';
/**
* Hook to detect if the user is on a mobile device
* Uses both media query and user agent for robust detection
*
* @param breakpoint - The max-width breakpoint for mobile (default: 768px)
* @returns boolean - true if mobile, false if desktop
*/
export function useIsMobile(breakpoint: number = 768): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
// Initial detection (SSR-safe)
if (typeof window === 'undefined') return false;
// Check media query
const mediaMatch = window.matchMedia(`(max-width: ${breakpoint}px)`).matches;
// Check user agent for mobile devices
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['android', 'webos', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
const isUserAgentMobile = mobileKeywords.some(keyword => userAgent.includes(keyword));
// Return true if either media query or user agent indicates mobile
return mediaMatch || isUserAgentMobile;
});
useEffect(() => {
// Create media query list
const mediaQueryList = window.matchMedia(`(max-width: ${breakpoint}px)`);
// Handler for media query changes
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
// Listen for changes (modern browsers)
if (mediaQueryList.addEventListener) {
mediaQueryList.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQueryList.addListener(handleChange);
}
// Cleanup
return () => {
if (mediaQueryList.removeEventListener) {
mediaQueryList.removeEventListener('change', handleChange);
} else {
mediaQueryList.removeListener(handleChange);
}
};
}, [breakpoint]);
return isMobile;
}
/**
* Hook to detect device type with more granularity
* @returns object with device type information
*/
export function useDeviceType() {
const isMobile = useIsMobile(768);
const isTablet = useIsMobile(1024) && !useIsMobile(768);
const isDesktop = !useIsMobile(1024);
return {
isMobile,
isTablet,
isDesktop,
deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'
} as const;
}
+143 -126
View File
@@ -2,9 +2,14 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/*
* Social Flow — design tokens.
* Toutes les couleurs de l'interface passent par ces variables (triplets HSL)
* afin que les modes clair et sombre restent cohérents.
*/
:root { :root {
--background: 0 0% 98%; --background: 210 40% 98%; /* #F8FAFC */
--foreground: 222 47% 11%; --foreground: 222 47% 11%; /* #0F172A */
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222 47% 11%; --card-foreground: 222 47% 11%;
@@ -12,96 +17,117 @@
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 47% 11%; --popover-foreground: 222 47% 11%;
--primary: 215 85% 58%; --primary: 243 75% 59%; /* #4F46E5 indigo */
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 265 75% 62%; --secondary: 220 14% 94%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%; --brand-accent: 6 93% 68%; /* #F97362 corail */
--muted-foreground: 215 16% 47%; --brand-accent-foreground: 0 0% 100%;
--accent: 210 40% 96%; --muted: 220 14% 95%;
--accent-foreground: 222 47% 11%; --muted-foreground: 215 16% 42%;
--destructive: 0 85% 62%; --accent: 226 100% 97%;
--accent-foreground: 243 60% 40%;
--destructive: 0 72% 51%; /* #DC2626 */
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 214 32% 91%; --success: 142 72% 37%; /* #16A34A */
--input: 214 32% 91%; --success-foreground: 0 0% 100%;
--ring: 215 85% 58%; --warning: 38 92% 50%; /* #F59E0B */
--warning-foreground: 26 90% 14%;
--info: 200 98% 39%; /* #0284C7 */
--info-foreground: 0 0% 100%;
--success: 142 76% 48%; --facebook: 214 89% 52%; /* #1877F2 */
--warning: 35 95% 58%; --instagram: 345 75% 57%; /* #E4405F */
--info: 195 85% 55%; --tiktok: 0 0% 7%;
--chart-1: 215 85% 58%; --border: 220 13% 90%;
--chart-2: 265 75% 62%; --input: 220 13% 87%;
--chart-3: 142 76% 48%; --ring: 243 75% 59%;
--chart-4: 195 85% 55%;
--chart-5: 35 95% 58%; --chart-1: 243 75% 59%;
--chart-2: 6 93% 68%;
--chart-3: 142 72% 37%;
--chart-4: 200 98% 39%;
--chart-5: 38 92% 50%;
--sidebar: 0 0% 100%; --sidebar: 0 0% 100%;
--sidebar-foreground: 222 47% 11%; --sidebar-foreground: 222 47% 11%;
--sidebar-primary: 215 85% 58%; --sidebar-primary: 243 75% 59%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96%; --sidebar-accent: 220 14% 96%;
--sidebar-accent-foreground: 222 47% 11%; --sidebar-accent-foreground: 222 47% 11%;
--sidebar-border: 214 32% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 215 85% 58%; --sidebar-ring: 243 75% 59%;
--radius: 0.75rem; --radius: 0.75rem;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', Menlo, monospace;
} }
.dark { .dark {
--background: 222 20% 8%; --background: 224 47% 7%;
--foreground: 0 0% 95%; --foreground: 210 40% 96%;
--card: 222 16% 15%; --card: 224 40% 11%;
--card-foreground: 0 0% 95%; --card-foreground: 210 40% 96%;
--popover: 222 18% 12%; --popover: 224 40% 12%;
--popover-foreground: 0 0% 95%; --popover-foreground: 210 40% 96%;
--primary: 215 85% 58%; --primary: 239 84% 70%;
--primary-foreground: 0 0% 100%; --primary-foreground: 224 47% 9%;
--secondary: 265 75% 62%; --secondary: 223 30% 18%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 210 40% 96%;
--muted: 222 12% 20%; --brand-accent: 6 93% 70%;
--muted-foreground: 222 10% 65%; --brand-accent-foreground: 224 47% 9%;
--accent: 222 18% 12%; --muted: 223 30% 16%;
--accent-foreground: 0 0% 95%; --muted-foreground: 217 20% 68%;
--destructive: 0 85% 62%; --accent: 236 40% 20%;
--accent-foreground: 230 90% 88%;
--destructive: 0 84% 63%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 222 12% 20%; --success: 142 64% 50%;
--input: 222 12% 20%; --success-foreground: 144 60% 8%;
--ring: 215 85% 58%; --warning: 43 96% 56%;
--warning-foreground: 26 90% 10%;
--info: 199 89% 60%;
--info-foreground: 204 80% 10%;
--success: 142 76% 48%; --facebook: 214 89% 62%;
--warning: 35 95% 58%; --instagram: 345 80% 65%;
--info: 195 85% 55%; --tiktok: 0 0% 96%;
--chart-1: 215 85% 58%; --border: 223 25% 20%;
--chart-2: 265 75% 62%; --input: 223 25% 24%;
--chart-3: 142 76% 48%; --ring: 239 84% 70%;
--chart-4: 195 85% 55%;
--chart-5: 35 95% 58%;
--sidebar: 222 20% 8%; --chart-1: 239 84% 70%;
--sidebar-foreground: 0 0% 95%; --chart-2: 6 93% 70%;
--sidebar-primary: 215 85% 58%; --chart-3: 142 64% 50%;
--sidebar-primary-foreground: 0 0% 100%; --chart-4: 199 89% 60%;
--sidebar-accent: 222 18% 12%; --chart-5: 43 96% 56%;
--sidebar-accent-foreground: 0 0% 95%;
--sidebar-border: 222 12% 20%; --sidebar: 224 45% 9%;
--sidebar-ring: 215 85% 58%; --sidebar-foreground: 210 40% 96%;
--sidebar-primary: 239 84% 70%;
--sidebar-primary-foreground: 224 47% 9%;
--sidebar-accent: 223 30% 15%;
--sidebar-accent-foreground: 210 40% 96%;
--sidebar-border: 223 25% 16%;
--sidebar-ring: 239 84% 70%;
} }
@layer base { @layer base {
@@ -109,50 +135,75 @@
@apply border-border; @apply border-border;
} }
html {
-webkit-tap-highlight-color: transparent;
}
body { body {
@apply font-sans antialiased bg-background text-foreground; @apply font-sans antialiased bg-background text-foreground;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@apply font-semibold; @apply font-semibold tracking-tight;
} }
} }
/* Custom scrollbar */ @layer utilities {
/* Dégradé de marque : réservé au logo et au bouton d'action principal. */
.gradient-brand {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
}
/* Alias historique conservé pour les composants existants. */
.gradient-primary {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
}
.gradient-text {
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--brand-accent)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.scrollbar-none {
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
}
/* Barre de défilement discrète */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;
height: 10px; height: 10px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: hsl(var(--muted) / 0.5); background: transparent;
border-radius: 5px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--secondary))); background: hsl(var(--muted-foreground) / 0.25);
border-radius: 5px; border-radius: 9999px;
border: 2px solid hsl(var(--background)); border: 2px solid hsl(var(--background));
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(var(--primary) / 0.9), hsl(var(--secondary) / 0.9)); background: hsl(var(--muted-foreground) / 0.4);
} }
/* Smooth transitions */ /* Apparition douce des contenus */
* {
transition: background-color 200ms, border-color 200ms, color 200ms;
}
button, a {
transition: all 200ms;
}
/* Animation for cards */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(6px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -161,64 +212,23 @@ button, a {
} }
.fade-in { .fade-in {
animation: fadeIn 150ms ease-out; animation: fadeIn 180ms ease-out;
} }
/* Gradient utilities */ /* Survol des cartes cliquables */
.gradient-primary {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Card hover effect */
.card-hover { .card-hover {
transition: transform 200ms, box-shadow 200ms; transition: transform 150ms, box-shadow 150ms, border-color 150ms;
} }
.card-hover:hover { .card-hover:hover {
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 12px 24px hsl(var(--primary) / 0.12); border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 8px 20px -8px hsl(var(--primary) / 0.25);
} }
/* Platform badges */ /* Squelette de chargement */
.badge-facebook {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border: 1px solid hsl(var(--primary) / 0.25);
}
.badge-instagram {
background: hsl(var(--secondary) / 0.1);
color: hsl(var(--secondary));
border: 1px solid hsl(var(--secondary) / 0.25);
}
/* Status indicators */
.status-scheduled {
color: hsl(var(--primary));
}
.status-published {
color: hsl(var(--success));
}
.status-failed {
color: hsl(var(--destructive));
}
.status-draft {
color: hsl(var(--muted-foreground));
}
/* Skeleton loader */
.skeleton { .skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--accent)) 50%, hsl(var(--muted)) 75%); background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--secondary)) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: loading 1.5s infinite;
} }
@@ -227,3 +237,10 @@ button, a {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
@media (prefers-reduced-motion: reduce) {
.fade-in,
.skeleton {
animation: none;
}
}
+84 -5
View File
@@ -1,4 +1,4 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; import { MutationCache, QueryClient, QueryFunction } from "@tanstack/react-query";
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher // Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login). // de redirection automatique (ex: mauvais mot de passe sur /login).
@@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) {
window.location.href = "/login"; window.location.href = "/login";
} }
/**
* Extrait un message lisible d'une erreur d'API.
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
* décodage, l'interface ne peut afficher qu'un message générique alors que le
* serveur a renvoyé la cause exacte.
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error) || !error.message) return fallback;
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
if (!withoutStatus) return fallback;
try {
const parsed = JSON.parse(withoutStatus);
const message = parsed?.error || parsed?.message;
if (typeof message === 'string' && message.trim()) return message.trim();
} catch {
// Corps non-JSON : on affiche le texte tel quel
}
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
}
async function throwIfResNotOk(res: Response, url: string) { async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
@@ -46,13 +69,40 @@ export async function apiRequest(
return res; return res;
} }
/**
* Construit l'URL d'une requête à partir de sa clé.
*
* Les segments texte forment le chemin (`['/api/posts', id]`). Un objet en
* dernière position porte les paramètres de requête : c'est ce qui permet de
* demander une fenêtre de dates plutôt que tout l'historique
* (`['/api/scheduled-posts', { startDate, endDate }]`).
*/
export function urlFromQueryKey(queryKey: readonly unknown[]): string {
const segments = [...queryKey];
const last = segments[segments.length - 1];
if (last !== null && typeof last === "object") {
segments.pop();
const params = new URLSearchParams();
for (const [key, value] of Object.entries(last as Record<string, unknown>)) {
if (value !== undefined && value !== null && value !== "") {
params.set(key, String(value));
}
}
const query = params.toString();
return query ? `${segments.join("/")}?${query}` : segments.join("/");
}
return segments.join("/");
}
type UnauthorizedBehavior = "returnNull" | "throw"; type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior; on401: UnauthorizedBehavior;
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const url = queryKey.join("/") as string; const url = urlFromQueryKey(queryKey);
const res = await fetch(url, { const res = await fetch(url, {
credentials: "include", credentials: "include",
}); });
@@ -65,13 +115,42 @@ export const getQueryFn: <T>(options: {
return await res.json(); return await res.json();
}; };
/**
* Marque les mutations d'authentification, exclues de l'invalidation globale
* ci-dessous. À passer en `mutationKey` : `[AUTH_MUTATION, 'logout']`.
*/
export const AUTH_MUTATION = "auth";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
// Les compteurs du tableau de bord et la liste des posts dérivent de tout le
// reste : publier, supprimer un média, générer un texte… Aucune mutation ne
// peut raisonnablement se souvenir de les invalider, et aucune ne le faisait :
// ils restaient figés jusqu'au rechargement de la page. On les invalide donc
// ici, une bonne fois, après chaque mutation réussie.
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
// Sauf pour l'authentification : au moment où la déconnexion réussit, la
// session est déjà détruite. Relancer des requêtes ici ne produirait que
// des 401 et une redirection brutale, avant même que le cache soit vidé.
if (mutation.options.mutationKey?.[0] === AUTH_MUTATION) return;
queryClient.invalidateQueries({ queryKey: ["/api/stats"] });
queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
queryFn: getQueryFn({ on401: "throw" }), queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false, // Le serveur agit de son côté — le scheduler publie toutes les minutes, le
refetchOnWindowFocus: false, // renouvellement des jetons tourne en tâche de fond — sans que le
staleTime: Infinity, // navigateur en soit averti. Avec `staleTime: Infinity`, un écran restait
// sur le cache de sa première visite pour toute la session : d'où la
// nécessité de recharger la page pour voir quoi que ce soit. Une
// péremption courte, plus un rafraîchissement au retour sur l'onglet,
// suffit à garder l'affichage juste sans marteler l'API.
staleTime: 30_000,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: false, retry: false,
}, },
mutations: { mutations: {
-38
View File
@@ -1,38 +0,0 @@
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
import { useState } from "react";
export default function AnalyticsPage() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight mb-2">Analystique Sociale</h1>
<p className="text-muted-foreground">Suivez vos performances Facebook & Instagram.</p>
</div>
<AnalyticsDashboard />
</div>
</main>
</div>
);
}
+9 -33
View File
@@ -1,41 +1,17 @@
import { useState } from "react"; import { Bot } from "lucide-react";
import Sidebar from "@/components/sidebar"; import { Page } from "@/components/layout/app-shell";
import TopBar from "@/components/topbar"; import { PageHeader } from "@/components/layout/page-header";
import AiChat from "@/components/ai-chat"; import AiChat from "@/components/ai-chat";
export default function AI() { export default function AI() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="default">
{sidebarOpen && ( <PageHeader
<div icon={Bot}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Assistant IA"
onClick={() => setSidebarOpen(false)} description="Discutez avec l'IA pour trouver des idées et rédiger vos textes."
/> />
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Assistant IA</h1>
<p className="text-muted-foreground mt-2">
Générez du contenu personnalisé avec l'intelligence artificielle
</p>
</div>
<AiChat /> <AiChat />
</div> </Page>
</main>
</div>
); );
} }
+17
View File
@@ -0,0 +1,17 @@
import { BarChart3 } from "lucide-react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
export default function AnalyticsPage() {
return (
<Page width="wide">
<PageHeader
icon={BarChart3}
title="Statistiques"
description="Suivez l'audience et l'engagement de vos comptes."
/>
<AnalyticsDashboard />
</Page>
);
}
+95 -110
View File
@@ -1,8 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar"; import { EmptyState } from "@/components/empty-state";
import TopBar from "@/components/topbar"; import {
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react"; import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
@@ -20,10 +30,10 @@ interface AudioTrack {
} }
export default function AudioAdmin() { export default function AudioAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [playingTrackId, setPlayingTrackId] = useState<number | null>(null); const [playingTrackId, setPlayingTrackId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null); const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [trackToDelete, setTrackToDelete] = useState<AudioTrack | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -58,7 +68,7 @@ export default function AudioAdmin() {
const successCount = data.results?.filter((r: any) => r.success).length ?? 0; const successCount = data.results?.filter((r: any) => r.success).length ?? 0;
const failCount = data.results?.filter((r: any) => !r.success).length ?? 0; const failCount = data.results?.filter((r: any) => !r.success).length ?? 0;
toast({ toast({
title: "Upload terminé", title: "Import terminé",
description: failCount > 0 description: failCount > 0
? `${successCount} fichier(s) ajouté(s), ${failCount} échec(s)` ? `${successCount} fichier(s) ajouté(s), ${failCount} échec(s)`
: `${successCount} fichier(s) ajouté(s) à la bibliothèque`, : `${successCount} fichier(s) ajouté(s) à la bibliothèque`,
@@ -149,37 +159,7 @@ export default function AudioAdmin() {
}; };
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="default">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto w-full">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Music className="w-8 h-8 text-primary" />
Bibliothèque Audio
</h1>
<p className="text-muted-foreground mt-2">
Gérez les musiques disponibles pour la création de Reels
</p>
</div>
<div>
<input <input
type="file" type="file"
id="audio-upload" id="audio-upload"
@@ -189,100 +169,105 @@ export default function AudioAdmin() {
onChange={handleFileUpload} onChange={handleFileUpload}
disabled={isUploading} disabled={isUploading}
/> />
<PageHeader
icon={Music}
title="Musiques"
description="Les pistes proposées lors de la création d'un Reel."
actions={
<Button asChild disabled={isUploading} className="cursor-pointer">
<label htmlFor="audio-upload"> <label htmlFor="audio-upload">
<Button {isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
asChild {isUploading ? "Import en cours…" : "Importer des musiques"}
disabled={isUploading}
className="cursor-pointer"
>
<span>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? "Upload en cours..." : "Ajouter un fichier audio"}
</span>
</Button>
</label> </label>
</div> </Button>
</div> }
/>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle>Musiques ({tracks.length})</CardTitle>
<CardDescription>
Ces pistes pourront être sélectionnées par les utilisateurs lors de la création d'un nouveau Reel.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center p-8"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> {[1, 2, 3].map((i) => (
<div key={i} className="skeleton h-[76px] rounded-xl" />
))}
</div> </div>
) : tracks.length === 0 ? ( ) : tracks.length === 0 ? (
<div className="text-center p-12 border-2 border-dashed border-border rounded-xl"> <EmptyState
<Music className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" /> icon={Music}
<h3 className="text-lg font-medium mb-1">Aucune musique</h3> title="Aucune musique"
<p className="text-muted-foreground"> description="Importez des fichiers audio (MP3, WAV…) pour les proposer dans vos Reels."
Vous n'avez pas encore ajouté de piste audio. Les musiques ajoutées ici seront utilisables pour les Reels. action={
</p> <Button asChild className="cursor-pointer">
</div> <label htmlFor="audio-upload">
<Upload className="h-4 w-4" /> Importer des musiques
</label>
</Button>
}
/>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <>
{tracks.map((track) => ( <p className="mb-3 text-sm text-muted-foreground">{tracks.length} piste(s)</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tracks.map((track) => {
const playing = playingTrackId === track.id;
return (
<div <div
key={track.id} key={track.id}
className="bg-card border border-border/50 rounded-xl p-4 flex flex-col gap-4 hover:border-primary/30 transition-colors shadow-sm" className={`flex items-center gap-3 rounded-xl border bg-card p-3 shadow-soft transition-colors ${playing ? "border-primary/50" : ""}`}
data-testid={`track-${track.id}`}
> >
<div className="flex items-start justify-between gap-3"> <Button
<div className="p-3 bg-primary/10 rounded-lg shrink-0"> variant={playing ? "default" : "secondary"}
<Music className="w-6 h-6 text-primary" /> size="icon"
</div> className="h-11 w-11 shrink-0 rounded-full"
<div className="flex-1 min-w-0"> onClick={() => togglePlay(track)}
<p className="font-semibold text-sm truncate" title={track.title}>{track.title}</p> aria-label={playing ? "Pause" : "Écouter"}
<p className="text-xs text-muted-foreground mt-1"> >
{format(new Date(track.createdAt), "dd MMM yyyy", { locale: fr })} {playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={track.title}>{track.title}</p>
<p className="text-xs text-muted-foreground">
Ajoutée le {format(new Date(track.createdAt), "d MMM yyyy", { locale: fr })}
</p> </p>
</div> </div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className={playingTrackId === track.id ? "text-primary bg-primary/10" : ""}
onClick={() => togglePlay(track)}
>
{playingTrackId === track.id ? (
<><Pause className="w-4 h-4 mr-2" /> Pause</>
) : (
<><Play className="w-4 h-4 mr-2" /> Écouter</>
)}
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => { onClick={() => setTrackToDelete(track)}
if (confirm("Voulez-vous vraiment supprimer cette musique ?")) {
deleteMutation.mutate(track.id);
}
}}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
aria-label="Supprimer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
);
})}
</div> </div>
))} </>
</div>
)} )}
</CardContent>
</Card> <AlertDialog open={!!trackToDelete} onOpenChange={(open) => !open && setTrackToDelete(null)}>
</div> <AlertDialogContent>
</main> <AlertDialogHeader>
</div> <AlertDialogTitle>Supprimer cette musique ?</AlertDialogTitle>
<AlertDialogDescription>
« {trackToDelete?.title} » ne sera plus proposée dans les nouveaux Reels.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (trackToDelete) deleteMutation.mutate(trackToDelete.id);
setTrackToDelete(null);
}}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Page>
); );
} }
+18 -33
View File
@@ -1,41 +1,26 @@
import { useState } from "react"; import { Link } from "wouter";
import Sidebar from "@/components/sidebar"; import { Calendar as CalendarIcon, Plus } from "lucide-react";
import TopBar from "@/components/topbar"; import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import CalendarView from "@/components/calendar-view"; import CalendarView from "@/components/calendar-view";
export default function Calendar() { export default function Calendar() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="wide">
{sidebarOpen && ( <PageHeader
<div icon={CalendarIcon}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Calendrier"
onClick={() => setSidebarOpen(false)} description="Visualisez et gérez vos publications programmées."
actions={
<Button asChild>
<Link href="/new">
<Plus className="h-4 w-4" /> Programmer
</Link>
</Button>
}
/> />
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Calendrier</h1>
<p className="text-muted-foreground mt-2">
Visualisez et gérez vos publications planifiées
</p>
</div>
<CalendarView /> <CalendarView />
</div> </Page>
</main>
</div>
); );
} }
+38 -25
View File
@@ -1,43 +1,56 @@
import Sidebar from "@/components/sidebar"; import { Link } from "wouter";
import TopBar from "@/components/topbar"; import { Film, PenSquare } from "lucide-react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import StatsCards from "@/components/stats-cards"; import StatsCards from "@/components/stats-cards";
import OngoingReels from "@/components/ongoing-reels"; import OngoingReels from "@/components/ongoing-reels";
import RecentPublications from "@/components/recent-publications"; import RecentPublications from "@/components/recent-publications";
import ManagedPages from "@/components/managed-pages"; import ManagedPages from "@/components/managed-pages";
import { useState } from "react"; import { useSession } from "@/hooks/use-session";
function greeting(): string {
const h = new Date().getHours();
if (h < 5 || h >= 18) return "Bonsoir";
return "Bonjour";
}
export default function Dashboard() { export default function Dashboard() {
const [sidebarOpen, setSidebarOpen] = useState(false); const { session } = useSession();
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="wide">
{sidebarOpen && ( <PageHeader
<div title={`${greeting()}${session ? ` ${session.username}` : ""} 👋`}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" description="Voici un aperçu de votre activité sur les réseaux sociaux."
onClick={() => setSidebarOpen(false)} actions={
<>
<Button asChild variant="outline" className="flex-1 sm:flex-none">
<Link href="/reel">
<Film className="h-4 w-4" /> Nouveau Reel
</Link>
</Button>
<Button asChild variant="brand" className="flex-1 sm:flex-none">
<Link href="/new">
<PenSquare className="h-4 w-4" /> Nouvelle publication
</Link>
</Button>
</>
}
/> />
)}
<div className={` <div className="space-y-6">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
<StatsCards /> <StatsCards />
<OngoingReels /> <OngoingReels />
<div className="grid gap-6 xl:grid-cols-5">
<div className="xl:col-span-3">
<RecentPublications /> <RecentPublications />
</div>
<div className="xl:col-span-2">
<ManagedPages /> <ManagedPages />
</div> </div>
</main>
</div> </div>
</div>
</Page>
); );
} }
+157 -132
View File
@@ -1,9 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Clock, CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard } from "lucide-react"; import { CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react";
import Sidebar from "@/components/sidebar"; import { Link } from "wouter";
import TopBar from "@/components/topbar"; import { Card } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon } from "@/components/platform-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -17,15 +22,41 @@ type ScheduledPostWithRelations = ScheduledPost & {
page?: SocialPage; page?: SocialPage;
}; };
/** Publication dont l'heure est passée mais que le scheduler n'a pas encore traitée. */
function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boolean {
return (
!!scheduledPost.scheduledAt &&
new Date(scheduledPost.scheduledAt) <= new Date() &&
!scheduledPost.publishedAt
);
}
type StatusFilter = "all" | "published" | "pending" | "failed";
const STATUS_TABS: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "Toutes" },
{ value: "published", label: "Publiées" },
{ value: "pending", label: "En cours" },
{ value: "failed", label: "Échecs" },
];
const PAGE_SIZE = 20;
export default function History() { export default function History() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[]; generationStatus?: string; generationProgress?: number }>({ postText: '', mediaIds: [], allMedia: [] }); const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[]; generationStatus?: string; generationProgress?: number }>({ postText: '', mediaIds: [], allMedia: [] });
const { toast } = useToast(); const { toast } = useToast();
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts'],
refetchInterval: 3000, // Le scheduler publie en tâche de fond : on ne suit à la seconde que ce qui
// est réellement en attente. Le reste du temps, une veille lente suffit —
// sans quoi la page entière était rechargée 20 fois par minute pour rien.
refetchInterval: (query) =>
(query.state.data ?? []).some(isAwaitingPublication) ? 3000 : 30000,
}); });
// Filter attempted posts (scheduled in the past) // Filter attempted posts (scheduled in the past)
@@ -68,11 +99,11 @@ export default function History() {
const getPostTypeIcon = (postType: string) => { const getPostTypeIcon = (postType: string) => {
if (postType === 'feed') { if (postType === 'feed') {
return <ImageIcon className="w-4 h-4" />; return <ImageIcon className="h-3.5 w-3.5" />;
} else if (postType === 'story') { } else if (postType === 'story') {
return <Smartphone className="w-4 h-4" />; return <Smartphone className="h-3.5 w-3.5" />;
} else if (postType === 'reel') { } else if (postType === 'reel') {
return <Clapperboard className="w-4 h-4" />; return <Clapperboard className="h-3.5 w-3.5" />;
} else if (postType === 'both') { } else if (postType === 'both') {
return ( return (
<div className="flex gap-0.5"> <div className="flex gap-0.5">
@@ -84,155 +115,150 @@ export default function History() {
return null; return null;
}; };
const statusOf = (sp: ScheduledPostWithRelations): StatusFilter =>
sp.error ? "failed" : !sp.publishedAt ? "pending" : "published";
const counts = publishedPosts.reduce(
(acc, sp) => {
acc[statusOf(sp)] += 1;
return acc;
},
{ all: publishedPosts.length, published: 0, pending: 0, failed: 0 } as Record<StatusFilter, number>,
);
const term = searchTerm.trim().toLowerCase();
const filteredPosts = publishedPosts.filter(
(sp) =>
(statusFilter === "all" || statusOf(sp) === statusFilter) &&
(!term ||
sp.post?.content?.toLowerCase().includes(term) ||
sp.page?.pageName?.toLowerCase().includes(term)),
);
const visiblePosts = filteredPosts.slice(0, visibleCount);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <>
{sidebarOpen && ( <Page width="default">
<div <PageHeader
className="fixed inset-0 bg-black/50 z-40 lg:hidden" icon={HistoryIcon}
onClick={() => setSidebarOpen(false)} title="Historique"
description="Toutes les publications envoyées et leur résultat."
/> />
)}
<div className={` <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out <Tabs value={statusFilter} onValueChange={(v) => { setStatusFilter(v as StatusFilter); setVisibleCount(PAGE_SIZE); }}>
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} <TabsList className="w-full justify-start overflow-x-auto scrollbar-none sm:w-auto">
`}> {STATUS_TABS.map((t) => (
<Sidebar onLinkClick={() => setSidebarOpen(false)} /> <TabsTrigger key={t.value} value={t.value} className="gap-1.5" data-testid={`tab-history-${t.value}`}>
{t.label}
<span className="rounded-full bg-muted-foreground/10 px-1.5 text-[11px] tabular-nums">{counts[t.value]}</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="relative sm:w-72">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher un texte ou une page…"
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setVisibleCount(PAGE_SIZE); }}
className="pl-9"
data-testid="input-history-search"
/>
</div> </div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Historique</h1>
<p className="text-muted-foreground mt-2">
Consultez l'historique de vos publications
</p>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="space-y-6"> <div className="space-y-3">
{[1, 2, 3].map((i) => ( {[1, 2, 3, 4].map((i) => (
<div key={i} className="h-40 bg-card rounded-2xl border border-border/50 animate-pulse" /> <div key={i} className="skeleton h-24 rounded-xl" />
))} ))}
</div> </div>
) : publishedPosts.length === 0 ? ( ) : publishedPosts.length === 0 ? (
<Card className="rounded-2xl border-border/50 border-dashed shadow-lg"> <EmptyState
<CardContent className="flex flex-col items-center justify-center py-16"> icon={HistoryIcon}
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mb-6"> title="Aucune publication"
<HistoryIcon className="w-10 h-10 text-muted-foreground opacity-50" /> description="Vos publications apparaîtront ici une fois envoyées."
</div> action={
<h3 className="text-xl font-semibold mb-2">Aucune publication</h3> <Button asChild>
<p className="text-muted-foreground text-center max-w-md"> <Link href="/new">Créer une publication</Link>
Vos publications apparaîtront ici une fois publiées
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{publishedPosts.map((scheduledPost) => {
const isPending = !scheduledPost.publishedAt;
const hasError = !!scheduledPost.error;
return (
<Card key={scheduledPost.id} className="rounded-2xl border-border/50 shadow-lg overflow-hidden" data-testid={`card-post-${scheduledPost.id}`}>
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-muted/20 to-muted/10 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shadow-md ${hasError ? 'bg-gradient-to-br from-destructive to-destructive/80' :
isPending ? 'bg-gradient-to-br from-blue-500 to-blue-600' :
'bg-gradient-to-br from-green-500 to-green-600'
}`}>
{hasError ? (
<XCircle className="w-5 h-5 text-white" />
) : isPending ? (
<Loader2 className="w-5 h-5 text-white animate-spin" />
) : (
<CheckCircle className="w-5 h-5 text-white fill-white" />
)}
</div>
<CardTitle className="text-lg">
{scheduledPost.page?.pageName || 'Page inconnue'}
</CardTitle>
</div>
<p className="text-sm text-foreground leading-relaxed line-clamp-3">
{scheduledPost.post?.content || 'Aucun texte'}
</p>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => handlePreviewPost(scheduledPost)}
className="h-9 w-9 p-0"
data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser"
>
<Eye className="w-4 h-4" />
</Button> </Button>
<Badge }
className={`${hasError />
? 'bg-destructive/20 text-destructive hover:bg-destructive/30' ) : filteredPosts.length === 0 ? (
: isPending <EmptyState icon={Search} title="Aucun résultat" description="Essayez un autre mot-clé ou un autre filtre." />
? 'bg-blue-500/20 text-blue-500 hover:bg-blue-500/30'
: 'bg-success/20 text-success hover:bg-success/30'
}`}
>
{hasError ? (
<>
<XCircle className="w-3 h-3 mr-1" />
Échoué
</>
) : isPending ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Traitement...
</>
) : ( ) : (
<> <div className="space-y-3">
<CheckCircle className="w-3 h-3 mr-1" /> {visiblePosts.map((scheduledPost) => {
Publié const status = statusOf(scheduledPost);
</> return (
<Card key={scheduledPost.id} className="p-4" data-testid={`card-post-${scheduledPost.id}`}>
<div className="flex items-start gap-3">
<PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium">{scheduledPost.page?.pageName || "Page inconnue"}</p>
{status === "failed" ? (
<Badge variant="danger" className="gap-1"><XCircle className="h-3 w-3" /> Échec</Badge>
) : status === "pending" ? (
<Badge variant="info" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" /> En cours</Badge>
) : (
<Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" /> Publié</Badge>
)}
{scheduledPost.post?.aiGenerated && (
<Badge variant="muted" className="gap-1"><Sparkles className="h-3 w-3" /> IA</Badge>
)} )}
</Badge>
</div> </div>
</div> <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
</CardHeader> {scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
<CardContent className="p-6"> </p>
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-wrap"> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
{getPostTypeIcon(scheduledPost.postType)} {getPostTypeIcon(scheduledPost.postType)}
<span className="font-medium capitalize"> {scheduledPost.postType === "feed"
{scheduledPost.postType === 'feed' ? 'Feed' : ? "Feed"
scheduledPost.postType === 'story' ? 'Story' : : scheduledPost.postType === "story"
scheduledPost.postType === 'reel' ? 'Reel' : 'Feed & Story'} ? "Story"
: scheduledPost.postType === "reel"
? "Reel"
: "Feed & Story"}
</span> </span>
</div> <span className="flex items-center gap-1.5">
<div className="flex items-center gap-2"> <Calendar className="h-3.5 w-3.5" />
<Calendar className="w-4 h-4" />
<span className="font-medium">
{scheduledPost.scheduledAt {scheduledPost.scheduledAt
? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr }) ? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
: 'Date inconnue' : "Date inconnue"}
}
</span> </span>
</div> </div>
{scheduledPost.post?.aiGenerated && ( {scheduledPost.error && (
<Badge variant="outline" className="text-xs bg-gradient-to-r from-primary/5 to-secondary/5 border-primary/20"> <p className="mt-2 rounded-md bg-destructive/10 px-2.5 py-1.5 text-xs text-destructive">{scheduledPost.error}</p>
<CheckCircle className="w-3 h-3 mr-1" />
Généré par IA
</Badge>
)} )}
</div> </div>
</CardContent> <Button
variant="ghost"
size="icon"
onClick={() => handlePreviewPost(scheduledPost)}
className="shrink-0 text-muted-foreground"
data-testid={`button-preview-post-${scheduledPost.id}`}
title="Prévisualiser"
aria-label="Prévisualiser"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</Card> </Card>
); );
})} })}
{filteredPosts.length > visibleCount && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} data-testid="button-history-more">
Afficher plus ({filteredPosts.length - visibleCount} restantes)
</Button>
</div> </div>
)} )}
</div> </div>
)}
</Page>
<PreviewModal <PreviewModal
open={previewModalOpen} open={previewModalOpen}
@@ -246,7 +272,6 @@ export default function History() {
generationStatus={previewData.generationStatus} generationStatus={previewData.generationStatus}
generationProgress={previewData.generationProgress} generationProgress={previewData.generationProgress}
/> />
</main> </>
</div>
); );
} }
+36 -53
View File
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,7 +13,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Wand2, Save, ArrowRight, Image as ImageIcon, X } from "lucide-react"; import { Wand2, Save, Image as ImageIcon, X } from "lucide-react";
import { Link } from "wouter";
import { EmptyState } from "@/components/empty-state";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
interface Ribbon { interface Ribbon {
@@ -39,7 +41,6 @@ interface Logo {
} }
export default function ImageEditor() { export default function ImageEditor() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<any>(null); const [selectedMedia, setSelectedMedia] = useState<any>(null);
const [ribbon, setRibbon] = useState<Ribbon>({ const [ribbon, setRibbon] = useState<Ribbon>({
@@ -119,7 +120,7 @@ export default function ImageEditor() {
}, [selectedMedia, ribbon, priceBadge, logo]); }, [selectedMedia, ribbon, priceBadge, logo]);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="wide">
<style>{` <style>{`
/* Triangle ribbon - auto-centered text */ /* Triangle ribbon - auto-centered text */
.ribbon-container { .ribbon-container {
@@ -212,44 +213,19 @@ export default function ImageEditor() {
} }
`}</style> `}</style>
{sidebarOpen && ( <PageHeader
<div icon={Wand2}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Éditeur d'images"
onClick={() => setSidebarOpen(false)} description="Ajoutez un ruban, un prix ou votre logo sur une image, puis enregistrez-la dans la médiathèque."
/> />
)}
<div className={` <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<Wand2 className="w-8 h-8" />
Éditeur d'images
</h1>
<p className="text-muted-foreground mt-2">
Ajoutez des rubans promotionnels, badges prix et filtres à vos images
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="space-y-6"> <div className="space-y-6">
{/* Image Selection */} {/* Image Selection */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>1. Choisissez une image</CardTitle>
<ImageIcon className="w-5 h-5" />
Sélectionner une image
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? ( {mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
@@ -260,7 +236,7 @@ export default function ImageEditor() {
<div <div
key={media.id} key={media.id}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${ className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50' selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary/30' : 'border-transparent hover:border-primary/50'
}`} }`}
onClick={() => setSelectedMedia(media)} onClick={() => setSelectedMedia(media)}
data-testid={`select-image-${media.id}`} data-testid={`select-image-${media.id}`}
@@ -274,9 +250,17 @@ export default function ImageEditor() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground text-center py-8"> <EmptyState
Aucune image disponible. Uploadez des images dans la médiathèque. compact
</p> icon={ImageIcon}
title="Aucune image disponible"
description="Importez d'abord des images dans la médiathèque."
action={
<Button asChild size="sm">
<Link href="/media">Ouvrir la médiathèque</Link>
</Button>
}
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -284,7 +268,7 @@ export default function ImageEditor() {
{/* Ribbon Controls */} {/* Ribbon Controls */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ruban promotionnel</CardTitle> <CardTitle>2. Ruban promotionnel</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -365,7 +349,7 @@ export default function ImageEditor() {
{/* Price Badge */} {/* Price Badge */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Badge prix</CardTitle> <CardTitle>3. Prix</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -450,7 +434,7 @@ export default function ImageEditor() {
{/* Logo */} {/* Logo */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Logo</CardTitle> <CardTitle>4. Logo</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -526,7 +510,7 @@ export default function ImageEditor() {
<CardContent> <CardContent>
{selectedMedia ? ( {selectedMedia ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center"> <div className="relative rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center">
<div ref={previewRef} className="relative"> <div ref={previewRef} className="relative">
<img <img
src={previewUrl || selectedMedia.originalUrl} src={previewUrl || selectedMedia.originalUrl}
@@ -606,7 +590,7 @@ export default function ImageEditor() {
<button <button
onClick={() => setSelectedMedia(null)} onClick={() => setSelectedMedia(null)}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors z-20" className="absolute top-2 right-2 p-1.5 bg-background/80 backdrop-blur hover:bg-background rounded-full text-foreground shadow transition-colors z-20"
data-testid="button-clear-selection" data-testid="button-clear-selection"
title="Désélectionner l'image" title="Désélectionner l'image"
> >
@@ -616,26 +600,25 @@ export default function ImageEditor() {
<Button <Button
className="w-full" className="w-full"
variant="outline" variant="brand"
data-testid="button-save" data-testid="button-save"
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 dans la médiathèque"}
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="aspect-square flex items-center justify-center bg-muted rounded-lg"> <div className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/40 text-sm text-muted-foreground">
<p className="text-muted-foreground">Sélectionnez une image</p> <ImageIcon className="h-8 w-8" />
Choisissez une image pour voir l'aperçu
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div> </Page>
</main>
</div>
); );
} }
+61 -42
View File
@@ -1,12 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { Lock, User, Sparkles } from "lucide-react"; import { AlertTriangle, CheckCircle2, Loader2, Lock, User } from "lucide-react";
import { Logo, LogoMark } from "@/components/brand/logo";
export default function Login() { export default function Login() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@@ -56,38 +56,59 @@ export default function Login() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-secondary/5 p-4"> <div className="flex min-h-screen bg-background">
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div> {/* Panneau de marque (desktop) */}
<div className="relative hidden flex-1 overflow-hidden gradient-brand lg:flex lg:flex-col lg:justify-between lg:p-12">
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-32 -left-16 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="relative flex items-center gap-3 text-white">
<LogoMark size={40} className="rounded-xl ring-1 ring-white/30" />
<span className="text-xl font-semibold tracking-tight">Social Flow</span>
</div>
<div className="relative max-w-md text-white">
<h2 className="text-4xl font-semibold leading-tight tracking-tight">
Vos réseaux sociaux, en pilote automatique.
</h2>
<ul className="mt-8 space-y-3 text-white/90">
{[
"Créez publications et Reels en quelques clics",
"Programmez sur Facebook, Instagram et TikTok",
"Laissez l'IA rédiger vos textes",
].map((t) => (
<li key={t} className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 shrink-0" />
{t}
</li>
))}
</ul>
</div>
<p className="relative text-sm text-white/70">© Social Flow</p>
</div>
<Card className="w-full max-w-md rounded-2xl border-border/50 shadow-2xl relative z-10"> {/* Formulaire */}
<CardHeader className="space-y-4 text-center pt-8 pb-6"> <div className="flex flex-1 items-center justify-center p-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-secondary mx-auto flex items-center justify-center shadow-2xl"> <div className="w-full max-w-sm">
<Sparkles className="w-10 h-10 text-white" /> <div className="mb-8 lg:hidden">
<Logo size={44} />
</div> </div>
<div> <h1 className="text-2xl font-semibold tracking-tight">Connexion</h1>
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> <p className="mt-1 text-sm text-muted-foreground">Connectez-vous pour gérer vos publications.</p>
Social Flow
</CardTitle> <form onSubmit={handleSubmit} className="mt-8 space-y-5">
<CardDescription className="text-base mt-3"> <div className="space-y-2">
Connectez-vous à votre compte pour gérer vos publications <Label htmlFor="username" data-testid="label-username">
</CardDescription>
</div>
</CardHeader>
<CardContent className="pb-8 px-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3">
<Label htmlFor="username" className="text-sm font-semibold" data-testid="label-username">
Nom d'utilisateur Nom d'utilisateur
</Label> </Label>
<div className="relative"> <div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" /> <User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="username" id="username"
type="text" type="text"
autoComplete="username"
placeholder="admin" placeholder="admin"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="pl-12 h-12 rounded-xl bg-muted/30 border-border/50 focus:border-primary/50" className="h-11 pl-10"
required required
disabled={isLoading} disabled={isLoading}
data-testid="input-username" data-testid="input-username"
@@ -95,19 +116,20 @@ export default function Login() {
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-semibold" data-testid="label-password"> <Label htmlFor="password" data-testid="label-password">
Mot de passe Mot de passe
</Label> </Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="password" id="password"
type="password" type="password"
autoComplete="current-password"
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-12 h-12 rounded-xl bg-muted/30 border-border/50 focus:border-primary/50" className="h-11 pl-10"
required required
disabled={isLoading} disabled={isLoading}
data-testid="input-password" data-testid="input-password"
@@ -115,28 +137,25 @@ export default function Login() {
</div> </div>
</div> </div>
<Button <Button type="submit" variant="brand" size="lg" className="w-full" disabled={isLoading} data-testid="button-login">
type="submit" {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
className="w-full h-12 rounded-xl bg-gradient-to-r from-primary to-secondary hover:opacity-90 text-base font-semibold shadow-lg mt-6" {isLoading ? "Connexion en cours…" : "Se connecter"}
disabled={isLoading}
data-testid="button-login"
>
{isLoading ? "Connexion en cours..." : "Se connecter"}
</Button> </Button>
</form> </form>
{showDefaultPassword && ( {showDefaultPassword && (
<div className="mt-8 text-center p-4 rounded-xl bg-warning/10 border border-warning/20"> <div className="mt-6 flex gap-3 rounded-xl border border-warning/30 bg-warning/10 p-4 text-sm">
<p className="text-sm text-muted-foreground mb-2">Identifiants par défaut :</p> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="font-semibold text-foreground">admin / admin</p> <div>
<p className="text-xs mt-3 text-warning flex items-center justify-center gap-2"> <p className="text-foreground">
<span className="text-base">⚠️</span> Identifiants par défaut : <span className="font-semibold">admin / admin</span>
Changez ce mot de passe après la première connexion
</p> </p>
<p className="mt-1 text-muted-foreground">Changez ce mot de passe après la première connexion.</p>
</div>
</div> </div>
)} )}
</CardContent> </div>
</Card> </div>
</div> </div>
); );
} }
+9 -33
View File
@@ -1,41 +1,17 @@
import { useState } from "react"; import { Images } from "lucide-react";
import Sidebar from "@/components/sidebar"; import { Page } from "@/components/layout/app-shell";
import TopBar from "@/components/topbar"; import { PageHeader } from "@/components/layout/page-header";
import MediaUpload from "@/components/media-upload"; import MediaUpload from "@/components/media-upload";
export default function Media() { export default function Media() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="wide">
{sidebarOpen && ( <PageHeader
<div icon={Images}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Médiathèque"
onClick={() => setSidebarOpen(false)} description="Toutes vos images et vidéos, prêtes à être publiées."
/> />
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Médiathèque</h1>
<p className="text-muted-foreground mt-2">
Gérez vos images et vidéos pour vos publications
</p>
</div>
<MediaUpload /> <MediaUpload />
</div> </Page>
</main>
</div>
); );
} }
-42
View File
@@ -1,42 +0,0 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import AiChat from "@/components/ai-chat";
export default function AIMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Assistant IA</h1>
<p className="text-sm text-muted-foreground">
Générez du contenu personnalisé avec l'intelligence artificielle
</p>
</div>
<AiChat />
</div>
</main>
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
import { useState } from "react";
export default function AnalyticsPageMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 space-y-6">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight mb-2">Analystique Sociale</h1>
<p className="text-sm text-muted-foreground">Suivez vos performances.</p>
</div>
<AnalyticsDashboard />
</div>
</main>
</div>
);
}
-2
View File
@@ -1,2 +0,0 @@
import AudioAdmin from "../audio-admin";
export default AudioAdmin;
-42
View File
@@ -1,42 +0,0 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import CalendarView from "@/components/calendar-view";
export default function CalendarMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout with reduced padding */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Calendrier</h1>
<p className="text-sm text-muted-foreground">
Visualisez et gérez vos publications planifiées
</p>
</div>
<CalendarView />
</div>
</main>
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
import TopBar from "@/components/topbar";
import OngoingReels from "@/components/ongoing-reels";
import { Link, useLocation } from "wouter";
import {
PlusCircle,
Video,
Calendar,
BarChart3,
Images,
Wand2,
Clock,
Settings,
Users,
ChevronRight,
Sparkles,
Film
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export default function DashboardMobile() {
const [, setLocation] = useLocation();
const mainActions = [
{ icon: PlusCircle, label: "Nv. Post", href: "/new", color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20", description: "Créer une publication" },
{ icon: Video, label: "Nv. Reel", href: "/reel", color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20", description: "Générer une vidéo IA" },
{ icon: Calendar, label: "Calendrier", href: "/calendar", color: "text-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/20", description: "Voir le planning" },
{ icon: BarChart3, label: "Analytics", href: "/analytics", color: "text-emerald-500", bg: "bg-emerald-500/10", border: "border-emerald-500/20", description: "Statistiques" },
];
const secondaryMenu = [
{ icon: Film, label: "Générateur Vidéo", href: "/remotion" },
{ icon: Images, label: "Médiathèque", href: "/media" },
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor" },
{ icon: Clock, label: "Historique", href: "/history" },
{ icon: Users, label: "Pages connectées", href: "/pages" },
{ icon: Settings, label: "Paramètres", href: "/settings" },
];
return (
<div className="flex h-screen overflow-hidden bg-background">
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => { }} hideMenuButton={true} />
<div className="p-4 space-y-6 pb-20">
{/* Header Section */}
<div className="flex items-center gap-3 mb-2">
<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="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Social Flow
</h1>
<p className="text-xs text-muted-foreground">Tableau de bord</p>
</div>
</div>
{/* 1. Status Section */}
<OngoingReels compact />
{/* 2. Main Action Grid */}
<Card className="rounded-2xl border-border/50 shadow-lg overflow-hidden">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base font-semibold">Actions Rapides</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="grid grid-cols-2 gap-3">
{mainActions.map((item) => {
const Icon = item.icon;
return (
<Link key={item.href} href={item.href}>
<div className={`
flex flex-col items-center justify-center p-4 rounded-xl border cursor-pointer transition-all active:scale-95 hover:opacity-80
${item.bg} ${item.border}
`}>
<div className={`p-2 rounded-full bg-background/80 shadow-sm mb-2`}>
<Icon className={`w-6 h-6 ${item.color}`} />
</div>
<span className="font-semibold text-sm text-foreground">{item.label}</span>
<span className="text-[10px] text-muted-foreground mt-1 text-center">{item.description}</span>
</div>
</Link>
);
})}
</div>
</CardContent>
</Card>
{/* 3. Secondary Menu List */}
<Card className="rounded-2xl border-border/50 shadow-lg overflow-hidden">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base font-semibold">Menu</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border/50">
{secondaryMenu.map((item) => {
const Icon = item.icon;
return (
<Link key={item.href} href={item.href}>
<div className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors cursor-pointer active:bg-muted">
<div className="p-2 rounded-lg bg-muted text-foreground">
<Icon className="w-5 h-5" />
</div>
<span className="flex-1 font-medium text-sm">{item.label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
</Link>
);
})}
</div>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
-148
View File
@@ -1,148 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
Calendar, CheckCircle, XCircle, Clock,
MoreHorizontal, Filter, Search, Loader2
} from "lucide-react";
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function MobileHistory() {
const [searchTerm, setSearchTerm] = useState("");
// Fetch posts history with polling to see status updates
const { data: posts } = useQuery<any[]>({
queryKey: ['/api/posts'],
refetchInterval: 3000, // Poll every 3 seconds
});
const getStatusIcon = (status: string) => {
switch (status) {
case 'published': return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'failed': return <XCircle className="w-5 h-5 text-red-500" />;
case 'scheduled': return <Clock className="w-5 h-5 text-amber-500" />;
case 'draft': return <Loader2 className="w-5 h-5 text-blue-500 animate-spin" />;
default: return <Clock className="w-5 h-5 text-muted-foreground" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'published': return "Publié";
case 'failed': return "Échec";
case 'scheduled': return "Programmé";
case 'draft': return "Traitement...";
default: return "Brouillon";
}
};
const filteredPosts = posts?.filter(post =>
post.content?.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
return (
<div className="min-h-screen bg-background">
{/* Mobile Header */}
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="-ml-2">
<MoreHorizontal className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[80%]">
<Sidebar />
</SheetContent>
</Sheet>
<h1 className="font-semibold text-lg">Historique</h1>
</div>
<Button variant="ghost" size="icon">
<Filter className="h-5 w-5" />
</Button>
</div>
<div className="p-4 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher une publication..."
className="pl-9 bg-secondary/50"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Timeline List */}
<div className="space-y-3">
{filteredPosts.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Clock className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p>Aucune publication trouvée</p>
</div>
) : (
filteredPosts.map((post) => (
<Card key={post.id} className="overflow-hidden border-none shadow-sm bg-card">
<CardContent className="p-0">
<div className="flex items-stretch">
<div className={`w-1.5 ${post.status === 'published' ? 'bg-green-500' :
post.status === 'failed' ? 'bg-red-500' :
post.status === 'draft' ? 'bg-blue-500' : 'bg-amber-500'
}`} />
<div className="flex-1 p-3">
<div className="flex justify-between items-start mb-2">
<Badge variant="outline" className="flex items-center gap-1.5 px-2 py-0.5 h-6 text-xs font-normal">
{getStatusIcon(post.status)}
{getStatusLabel(post.status)}
</Badge>
<span className="text-xs text-muted-foreground">
{post.scheduledFor ? format(new Date(post.scheduledFor), "d MMM HH:mm", { locale: fr }) : '-'}
</span>
</div>
<p className="text-sm line-clamp-2 text-foreground mb-2">
{post.content || "Sans contenu"}
</p>
<div className="flex items-center justify-between mt-2">
<div className="flex -space-x-2">
{/* Placeholder for page icons if available in data */}
<div className="w-6 h-6 rounded-full bg-blue-100 border border-background flex items-center justify-center text-[10px]">fb</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Voir détails</DropdownMenuItem>
<DropdownMenuItem className="text-red-500">Supprimer</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
</div>
);
}
-645
View File
@@ -1,645 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Wand2, Save, Image as ImageIcon, X } from "lucide-react";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { useToast } from "@/hooks/use-toast";
interface Ribbon {
enabled: boolean;
text: "Promo" | "Nouveauté" | "Arrivage" | "Stock limité";
color: "red" | "yellow";
position: "north_west" | "north_east";
}
interface PriceBadge {
enabled: boolean;
price: string;
color: "red" | "yellow";
position: "north_east" | "south_east" | "north_west" | "south_west";
size: number;
}
interface Logo {
enabled: boolean;
opacity: number;
position: "north_east" | "south_east" | "north_west" | "south_west" | "center";
size: "small" | "medium" | "large";
}
export default function ImageEditorMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<any>(null);
const [ribbon, setRibbon] = useState<Ribbon>({
enabled: false,
text: "Promo",
color: "red",
position: "north_west"
});
const [priceBadge, setPriceBadge] = useState<PriceBadge>({
enabled: false,
price: "",
color: "red",
position: "north_east",
size: 20
});
const [logo, setLogo] = useState<Logo>({
enabled: false,
opacity: 60,
position: "south_east",
size: "medium"
});
const [previewUrl, setPreviewUrl] = useState<string>("");
const { data: mediaList } = useQuery({
queryKey: ["/api/media"],
});
const { data: cloudinaryConfig } = useQuery({
queryKey: ['/api/cloudinary/config'],
});
const previewRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const saveImageMutation = useMutation({
mutationFn: async () => {
if (!selectedMedia) {
throw new Error("Aucune image sélectionnée");
}
const payload = {
imageUrl: selectedMedia.originalUrl,
ribbon: ribbon.enabled ? ribbon : undefined,
priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined,
logo: logo.enabled ? logo : undefined,
};
const response = await apiRequest('POST', '/api/media/apply-overlays', payload);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
toast({
title: "Image enregistrée !",
description: "L'image éditée a été ajoutée à votre médiathèque.",
});
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message || "Impossible d'enregistrer l'image.",
variant: "destructive"
});
}
});
useEffect(() => {
if (!selectedMedia) {
setPreviewUrl("");
return;
}
let url = selectedMedia.originalUrl;
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//;
const match = url.match(cloudinaryPattern);
if (!match) {
setPreviewUrl(url);
return;
}
const [fullMatch, cloudName] = match;
const imagePath = url.replace(fullMatch, '');
const transformations: string[] = [];
transformations.push('w_800,h_800,c_fit');
const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`;
setPreviewUrl(transformedUrl);
}, [selectedMedia, ribbon, priceBadge, logo]);
return (
<div className="flex h-screen overflow-hidden bg-background">
<style>{`
.ribbon-container {
position: absolute;
z-index: 10;
pointer-events: none;
}
.ribbon-container.north_west {
top: 0;
left: 0;
width: 140px;
height: 140px;
}
.ribbon-container.north_east {
top: 0;
right: 0;
width: 140px;
height: 140px;
}
.ribbon-triangle {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.ribbon-container.north_west .ribbon-triangle {
top: 0;
left: 0;
border-width: 140px 140px 0 0;
}
.ribbon-container.north_west .ribbon-triangle.red {
border-top-color: #FF0000;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_west .ribbon-triangle.yellow {
border-top-color: #FFC107;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle {
top: 0;
right: 0;
border-width: 0 140px 140px 0;
}
.ribbon-container.north_east .ribbon-triangle.red {
border-top-color: transparent;
border-right-color: #FF0000;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-container.north_east .ribbon-triangle.yellow {
border-top-color: transparent;
border-right-color: #FFC107;
border-bottom-color: transparent;
border-left-color: transparent;
}
.ribbon-text {
position: absolute;
color: white;
font-weight: bold;
z-index: 11;
text-transform: uppercase;
text-align: center;
white-space: nowrap;
}
.ribbon-container.north_west .ribbon-text {
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg) translateY(-20px);
}
.ribbon-container.north_east .ribbon-text {
top: 50%;
right: 50%;
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
}
`}</style>
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Wand2 className="w-7 h-7" />
Éditeur d'images
</h1>
<p className="text-sm text-muted-foreground">
Ajoutez des rubans, badges prix et filtres
</p>
</div>
{/* Preview Card - Top on mobile */}
{selectedMedia && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg overflow-hidden bg-muted aspect-square flex items-center justify-center relative">
<div ref={previewRef} className="relative max-w-full max-h-full">
<img
src={previewUrl || selectedMedia.originalUrl}
alt="Aperçu"
className="max-w-full max-h-[400px] w-auto h-auto"
data-testid="preview-image"
/>
{ribbon.enabled && ribbon.text && (
<div className={`ribbon-container ${ribbon.position}`}>
<div className={`ribbon-triangle ${ribbon.color}`} />
<div
className="ribbon-text"
style={{
fontSize: ribbon.text === "Stock limité" ? '18px' :
ribbon.text.length <= 5 ? '24px' :
ribbon.text.length <= 8 ? '20px' :
ribbon.text.length <= 11 ? '18px' : '14px',
lineHeight: ribbon.text === "Stock limité" ? '1.2' : 'normal',
whiteSpace: ribbon.text === "Stock limité" ? 'normal' : 'nowrap',
transform: ribbon.text === "Stock limité"
? (ribbon.position === "north_west"
? 'translate(-50%, -50%) rotate(-45deg) translateY(-30px) translateX(2px)'
: 'translate(50%, -50%) rotate(45deg) translateY(-30px) translateX(-2px)')
: undefined
}}
>
{ribbon.text === "Stock limité" ? (
<>
Stock
<br />
limité
</>
) : ribbon.text}
</div>
</div>
)}
{priceBadge.enabled && priceBadge.price && (
<div
data-badge-price="true"
className={`absolute ${
priceBadge.position === "north_east" ? "top-3 right-3" :
priceBadge.position === "south_east" ? "bottom-3 right-3" :
priceBadge.position === "south_west" ? "bottom-3 left-3" :
"top-3 left-3"
} px-4 py-2 rounded-full text-white font-bold z-10 flex items-center justify-center ${
priceBadge.color === "red" ? "bg-red-600" : "bg-yellow-500"
}`}
style={{ fontSize: `${priceBadge.size}px` }}
>
{priceBadge.price} €
</div>
)}
{logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && (
<img
src={`https://res.cloudinary.com/${(cloudinaryConfig as any).cloudName}/image/upload/${(cloudinaryConfig as any).logoPublicId}`}
alt="Logo"
className={`absolute ${
logo.position === "north_east" ? "top-3 right-3" :
logo.position === "south_east" ? "bottom-3 right-3" :
logo.position === "south_west" ? "bottom-3 left-3" :
logo.position === "center" ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" :
"top-3 left-3"
} z-10 max-w-full max-h-full object-contain`}
style={{
opacity: logo.opacity / 100,
width: logo.size === "small" ? "30%" : logo.size === "large" ? "60%" : "45%"
}}
/>
)}
</div>
<button
onClick={() => setSelectedMedia(null)}
className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full text-white transition-colors z-20 min-h-[44px] min-w-[44px] flex items-center justify-center"
data-testid="button-clear-selection"
title="Désélectionner l'image"
>
<X className="w-5 h-5" />
</button>
</div>
<Button
className="w-full min-h-[48px]"
variant="default"
data-testid="button-save"
onClick={() => saveImageMutation.mutate()}
disabled={saveImageMutation.isPending}
>
<Save className="w-5 h-5 mr-2" />
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Image Selection */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<ImageIcon className="w-5 h-5" />
Sélectionner une image
</CardTitle>
</CardHeader>
<CardContent>
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
<div className="grid grid-cols-2 gap-3 max-h-[300px] overflow-y-auto">
{(mediaList as any[])
.filter((m: any) => m.type === 'image')
.map((media: any) => (
<div
key={media.id}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
selectedMedia?.id === media.id ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
}`}
onClick={() => setSelectedMedia(media)}
data-testid={`select-image-${media.id}`}
>
<MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl}
alt=""
type="image"
className="w-full h-28 object-cover"
/>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
Aucune image disponible. Uploadez des images dans la médiathèque.
</p>
)}
</CardContent>
</Card>
{/* Ribbon Controls */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Ruban promotionnel</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="ribbon-enabled"
checked={ribbon.enabled}
onCheckedChange={(checked) => setRibbon({ ...ribbon, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-ribbon"
/>
<Label htmlFor="ribbon-enabled" className="cursor-pointer flex-1 text-sm">Afficher un ruban</Label>
</div>
{ribbon.enabled && (
<>
<div className="space-y-3">
<Label className="text-sm">Texte du ruban</Label>
<RadioGroup
value={ribbon.text}
onValueChange={(value) => setRibbon({ ...ribbon, text: value as any })}
className="space-y-2"
>
{["Promo", "Nouveauté", "Arrivage", "Stock limité"].map((text) => (
<div key={text} className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value={text} id={`ribbon-${text.toLowerCase()}`} data-testid={`radio-ribbon-${text.toLowerCase()}`} />
<Label htmlFor={`ribbon-${text.toLowerCase()}`} className="cursor-pointer flex-1 text-sm">{text}</Label>
</div>
))}
</RadioGroup>
</div>
<div className="space-y-3">
<Label className="text-sm">Couleur</Label>
<RadioGroup
value={ribbon.color}
onValueChange={(value) => setRibbon({ ...ribbon, color: value as any })}
className="space-y-2"
>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="red" id="ribbon-red" data-testid="radio-ribbon-red" />
<Label htmlFor="ribbon-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Rouge <Badge className="bg-red-600">Exemple</Badge>
</Label>
</div>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="yellow" id="ribbon-yellow" data-testid="radio-ribbon-yellow" />
<Label htmlFor="ribbon-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Jaune <Badge className="bg-yellow-500">Exemple</Badge>
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={ribbon.position} onValueChange={(value) => setRibbon({ ...ribbon, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-ribbon-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="north_east">Haut droite</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</CardContent>
</Card>
{/* Price Badge */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Badge prix</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="price-enabled"
checked={priceBadge.enabled}
onCheckedChange={(checked) => setPriceBadge({ ...priceBadge, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-price"
/>
<Label htmlFor="price-enabled" className="cursor-pointer flex-1 text-sm">Afficher le prix</Label>
</div>
{priceBadge.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="price-input" className="text-sm">Prix (€)</Label>
<Input
id="price-input"
type="text"
placeholder="19.99"
value={priceBadge.price}
onChange={(e) => setPriceBadge({ ...priceBadge, price: e.target.value })}
className="min-h-[48px]"
data-testid="input-price"
/>
</div>
<div className="space-y-3">
<Label className="text-sm">Couleur</Label>
<RadioGroup
value={priceBadge.color}
onValueChange={(value) => setPriceBadge({ ...priceBadge, color: value as any })}
className="space-y-2"
>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="red" id="price-red" data-testid="radio-price-red" />
<Label htmlFor="price-red" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Rouge <Badge className="bg-red-600">19.99€</Badge>
</Label>
</div>
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<RadioGroupItem value="yellow" id="price-yellow" data-testid="radio-price-yellow" />
<Label htmlFor="price-yellow" className="flex items-center gap-2 cursor-pointer flex-1 text-sm">
Jaune <Badge className="bg-yellow-500">19.99€</Badge>
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={priceBadge.position} onValueChange={(value) => setPriceBadge({ ...priceBadge, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-price-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_east">Haut droite</SelectItem>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="south_east">Bas droite</SelectItem>
<SelectItem value="south_west">Bas gauche</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-sm">Taille du texte</Label>
<span className="text-sm text-muted-foreground">{priceBadge.size}px</span>
</div>
<Slider
value={[priceBadge.size]}
onValueChange={(value) => setPriceBadge({ ...priceBadge, size: value[0] })}
min={12}
max={70}
step={1}
className="touch-manipulation"
data-testid="slider-price-size"
/>
</div>
</>
)}
</CardContent>
</Card>
{/* Logo */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Logo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3 p-3 rounded-lg border touch-manipulation">
<Checkbox
id="logo-enabled"
checked={logo.enabled}
onCheckedChange={(checked) => setLogo({ ...logo, enabled: checked as boolean })}
className="w-5 h-5"
data-testid="checkbox-logo-enabled"
/>
<Label htmlFor="logo-enabled" className="cursor-pointer flex-1 text-sm">
Afficher le logo
</Label>
</div>
{logo.enabled && (
<>
<div className="space-y-2">
<Label className="text-sm">Position</Label>
<Select value={logo.position} onValueChange={(value) => setLogo({ ...logo, position: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="north_west">Haut gauche</SelectItem>
<SelectItem value="north_east">Haut droit</SelectItem>
<SelectItem value="south_west">Bas gauche</SelectItem>
<SelectItem value="south_east">Bas droit</SelectItem>
<SelectItem value="center">Centre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Taille</Label>
<Select value={logo.size} onValueChange={(value) => setLogo({ ...logo, size: value as any })}>
<SelectTrigger className="min-h-[48px]" data-testid="select-logo-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Petit</SelectItem>
<SelectItem value="medium">Moyen</SelectItem>
<SelectItem value="large">Grand</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-sm">Opacité</Label>
<span className="text-sm text-muted-foreground">{logo.opacity}%</span>
</div>
<Slider
value={[logo.opacity]}
onValueChange={(value) => setLogo({ ...logo, opacity: value[0] })}
min={0}
max={100}
step={5}
className="touch-manipulation"
data-testid="slider-logo-opacity"
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { useState } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import MediaUpload from "@/components/media-upload";
export default function MediaMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Reduced padding for better space usage */}
<div className="p-4 space-y-4">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Médiathèque</h1>
<p className="text-sm text-muted-foreground">
Gérez vos images et vidéos pour vos publications
</p>
</div>
<MediaUpload />
</div>
</main>
</div>
);
}
-781
View File
@@ -1,781 +0,0 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { useDropzone } from "react-dropzone";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker";
function SortableMediaItem({
media,
index,
isSelected,
onToggle
}: {
media: Media;
index: number;
isSelected: boolean;
onToggle: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: media.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isVideo = media.type === 'video';
return (
<div
ref={setNodeRef}
style={style}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
isSelected
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-muted-foreground'
}`}
>
<button
onClick={onToggle}
className="w-full h-full min-h-[44px]"
data-testid={`button-select-media-${media.id}`}
>
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</button>
{isSelected && (
<>
<div className="absolute top-2 right-2 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-bold shadow-lg">
{index + 1}
</div>
<div
{...attributes}
{...listeners}
className="absolute top-2 left-2 w-10 h-10 bg-black/70 rounded-full flex items-center justify-center cursor-move hover:bg-black/90 transition-colors touch-manipulation"
>
<GripVertical className="w-5 h-5 text-white" />
</div>
</>
)}
</div>
);
}
export default function NewPostMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null);
const [productInfo, setProductInfo] = useState('');
const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
const [recentUploads, setRecentUploads] = useState<Media[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [postText, setPostText] = useState('');
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const mediaList = useMemo(() => {
const combined = [
...recentUploads,
...allMedia.filter(m => !recentUploads.some(r => r.id === m.id)),
];
return combined
.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
})
.slice(0, 12);
}, [allMedia, recentUploads]);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'],
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json();
},
onSuccess: (data: Media) => {
setRecentUploads(prev => [data, ...prev.filter(m => m.id !== data.id)]);
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedMedia(prev => {
if (prev.length >= 10) {
toast({
title: "Limite atteinte",
description: "Maximum 10 photos par publication",
variant: "destructive",
});
return prev;
}
return [...prev, data.id];
});
toast({
title: "Succès",
description: "Photo téléchargée avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de télécharger la photo",
variant: "destructive",
});
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
uploadMutation.mutate(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
"video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"],
},
maxSize: 52428800,
noClick: true,
noKeyboard: true,
});
const generateTextMutation = useMutation({
mutationFn: async (productInfoText: string) => {
const productInfo = {
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText,
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
};
const response = await apiRequest('POST', '/api/ai/generate', productInfo);
return response.json();
},
onSuccess: (data: any) => {
const variants = data.variants || [];
setGeneratedVariants(variants);
toast({
title: "Texte généré",
description: `${variants.length} variations créées`,
});
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de générer le texte",
variant: "destructive",
});
},
});
const createPostMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('POST', '/api/posts', data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({
title: "Publication créée",
description: "La publication a été créée avec succès",
});
navigate('/');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de créer la publication",
variant: "destructive",
});
},
});
const handleGenerateText = () => {
if (!productInfo.trim()) {
toast({
title: "Information manquante",
description: "Veuillez saisir les informations du produit",
variant: "destructive",
});
return;
}
generateTextMutation.mutate(productInfo);
};
const handleCreatePost = () => {
if (!postText.trim()) {
toast({
title: "Contenu requis",
description: "Veuillez saisir le contenu de la publication",
variant: "destructive",
});
return;
}
if (selectedPages.length === 0) {
toast({
title: "Page requise",
description: "Veuillez sélectionner au moins une page",
variant: "destructive",
});
return;
}
if ((postType === 'story' || postType === 'both') && selectedMedia.length === 0) {
toast({
title: "Média requis",
description: "Les stories nécessitent au moins une image ou vidéo",
variant: "destructive",
});
return;
}
createPostMutation.mutate({
content: postText,
scheduledFor: scheduledDate ? scheduledDate.toISOString() : undefined,
mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined,
pageIds: selectedPages,
postType,
});
};
const handleUseVariant = (text: string) => {
setPostText(text);
toast({
title: "Texte sélectionné",
description: "Le texte a été ajouté à votre publication",
});
};
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
let fileToUpload = file;
if (!file.name || file.name === 'blob' || file.name === '') {
const extension = file.type.split('/')[1] || 'jpg';
const newFileName = `camera-${Date.now()}.${extension}`;
fileToUpload = new File([file], newFileName, { type: file.type });
}
if (file.size > 52428800) {
toast({
title: "Fichier trop volumineux",
description: `La taille maximale est de 50 MB. Votre fichier fait ${(file.size / 1024 / 1024).toFixed(2)} MB`,
variant: "destructive",
});
e.target.value = '';
return;
}
uploadMutation.mutate(fileToUpload);
e.target.value = '';
}
};
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setSelectedMedia((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 space-y-6 pb-24">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Nouvelle publication</h1>
<p className="text-sm text-muted-foreground">
Créez et planifiez une nouvelle publication
</p>
</div>
{/* MOBILE: Single column layout */}
<div className="space-y-6">
{/* Média Card */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">Média</CardTitle>
<CardDescription className="text-xs">Sélectionnez ou uploadez</CardDescription>
</div>
<div className="flex gap-2">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
size="sm"
variant="outline"
className="min-h-[44px]"
data-testid="button-camera-capture"
>
<Camera className="w-5 h-5" />
</Button>
<Button
onClick={open}
disabled={uploadMutation.isPending}
size="sm"
variant="outline"
className="min-h-[44px]"
data-testid="button-upload-new-media"
>
<Upload className="w-5 h-5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<input
ref={cameraInputRef}
type="file"
accept="image/*,video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
<input {...getInputProps()} />
{mediaList.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg">
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground mb-4 text-sm">
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
</p>
<div className="flex flex-col gap-3">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
size="lg"
variant="outline"
className="w-full min-h-[48px]"
data-testid="button-camera-first"
>
<Camera className="w-5 h-5 mr-2" />
Prendre une photo
</Button>
<Button
onClick={open}
disabled={uploadMutation.isPending}
size="lg"
className="w-full min-h-[48px]"
data-testid="button-upload-first-media"
>
<Upload className="w-5 h-5 mr-2" />
Uploader une photo
</Button>
</div>
</div>
) : (
<>
{selectedMedia.length > 0 && (
<div className="mb-4">
<div className="text-sm font-medium text-muted-foreground mb-3">
Photos sélectionnées ({selectedMedia.length}/10)
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedMedia}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-2 gap-3 p-4 bg-accent/20 rounded-lg border border-accent">
{selectedMedia.map((mediaId, index) => {
const media = mediaList.find(m => m.id === mediaId);
if (!media) return null;
return (
<SortableMediaItem
key={media.id}
media={media}
index={index}
isSelected={true}
onToggle={() => {
setSelectedMedia(prev => prev.filter(id => id !== media.id));
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
<div className="text-sm font-medium text-muted-foreground mb-3">
Toutes les photos ({mediaList.length})
</div>
<div className="max-h-[400px] overflow-y-auto">
<div className="grid grid-cols-2 gap-3">
{mediaList.map((media) => {
const isSelected = selectedMedia.includes(media.id);
const isVideo = media.type === 'video';
return (
<button
key={media.id}
onClick={() => {
if (isSelected) {
setSelectedMedia(prev => prev.filter(id => id !== media.id));
} else if (selectedMedia.length < 10) {
setSelectedMedia(prev => [...prev, media.id]);
} else {
toast({
title: "Limite atteinte",
description: "Maximum 10 photos par publication",
variant: "destructive",
});
}
}}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all min-h-[44px] ${
isSelected
? 'border-primary ring-2 ring-primary opacity-50'
: 'border-transparent hover:border-muted-foreground'
}`}
data-testid={`button-select-media-${media.id}`}
>
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover"
/>
{isSelected && (
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
<div className="text-xs font-semibold text-primary bg-white px-2 py-1 rounded">Sélectionnée</div>
</div>
)}
</button>
);
})}
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Contenu Card */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Contenu</CardTitle>
<CardDescription className="text-xs">Informations du produit ou message</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="productInfo" className="text-sm">Informations du produit</Label>
<Textarea
id="productInfo"
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
rows={5}
className="mt-2"
data-testid="input-product-info"
/>
</div>
<Button
onClick={handleGenerateText}
disabled={generateTextMutation.isPending}
className="w-full min-h-[48px]"
size="lg"
data-testid="button-generate-text"
>
<Sparkles className="w-5 h-5 mr-2" />
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'}
</Button>
</CardContent>
</Card>
{/* Variations générées */}
{generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Variations générées par l'IA</CardTitle>
<CardDescription className="text-xs">Cliquez sur "Utiliser"</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{generatedVariants.map((variant, index) => (
<div
key={index}
className="p-4 rounded-lg border bg-card"
>
<div className="space-y-3">
<div className="text-xs font-semibold text-muted-foreground">
{variant.variant || `Version ${index + 1}`}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{variant.text}
</p>
<Button
size="sm"
onClick={() => handleUseVariant(variant.text)}
className="w-full min-h-[44px]"
data-testid={`button-use-variant-${index}`}
>
Utiliser
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Texte de la publication */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Texte de la publication</CardTitle>
<CardDescription className="text-xs">Écrivez ou générez le texte</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={postText}
onChange={(e) => setPostText(e.target.value)}
placeholder="Écrivez votre texte ici ou générez-le avec l'IA..."
rows={6}
data-testid="textarea-post-text"
/>
</CardContent>
</Card>
{/* Pages cibles */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Pages cibles</CardTitle>
<CardDescription className="text-xs">Sélectionnez les pages</CardDescription>
</CardHeader>
<CardContent>
{pages.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-3 text-sm">Aucune page connectée</p>
<Button
variant="link"
onClick={() => navigate('/pages')}
className="min-h-[44px]"
data-testid="link-add-pages"
>
Ajouter des pages
</Button>
</div>
) : (
<div className="space-y-4">
{pages.map((page) => (
<div key={page.id} className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-accent touch-manipulation">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
className="w-5 h-5"
data-testid={`checkbox-page-${page.id}`}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-relaxed flex-1 cursor-pointer"
>
{page.pageName} ({page.platform})
</label>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Type de publication */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Type de publication</CardTitle>
<CardDescription className="text-xs">Choisissez où publier</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Label htmlFor="postType">Format</Label>
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
<SelectTrigger id="postType" className="min-h-[48px]" data-testid="select-post-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem>
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem>
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{postType === 'feed' && 'Publication classique dans le fil d\'actualité'}
{postType === 'story' && 'Story éphémère (24h) - Nécessite un média'}
{postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'}
</p>
</CardContent>
</Card>
{/* Planification */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-lg">Planification</CardTitle>
<CardDescription className="text-xs">Programmez (optionnel)</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Label>Date et heure</Label>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={scheduledPosts
.filter(post => post.scheduledAt)
.map(post => new Date(post.scheduledAt!))}
placeholder="Publier immédiatement"
/>
<p className="text-xs text-muted-foreground">
Sélectionnez une date pour planifier, sinon publication immédiate
</p>
</CardContent>
</Card>
{/* Bouton Publier - Fixed au bottom */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
<Button
onClick={() => setPreviewModalOpen(true)}
disabled={createPostMutation.isPending}
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg min-h-[52px]"
size="lg"
data-testid="button-preview-post"
>
<Send className="w-5 h-5 mr-2" />
Prévisualiser et publier
</Button>
</div>
</div>
</div>
</main>
<Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
<div className="flex flex-col items-center justify-center py-8">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
Upload en cours...
</h3>
<p className="text-sm text-muted-foreground text-center">
Votre image est en cours de téléchargement et de traitement.
<br />
Veuillez patienter.
</p>
</div>
</DialogContent>
</Dialog>
<PreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
postText={postText}
selectedMedia={selectedMedia}
mediaList={mediaList}
onPublish={handleCreatePost}
isPublishing={createPostMutation.isPending}
/>
</div>
);
}
-630
View File
@@ -1,630 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import {
Send, Sparkles, Video, Music, Type, Calendar,
Upload, Play, Pause, Volume2,
ChevronRight, Loader2, Check, RefreshCw, ChevronLeft,
Menu, Mic
} from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number;
previewUrl: string;
downloadUrl: string;
imageUrl: string;
license: string;
}
type Step = 'video' | 'music' | 'text' | 'publish';
export default function MobileNewReel() {
const [, navigate] = useLocation();
const { toast } = useToast();
const audioRef = useRef<HTMLAudioElement>(null);
// État du workflow
const [currentStep, setCurrentStep] = useState<Step>('video');
// État des données
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState('');
const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [musicVolume, setMusicVolume] = useState([25]);
// État TTS
const [ttsEnabled, setTtsEnabled] = useState(false);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [stabilize, setStabilize] = useState(true); // Activé par défaut pour les Reels
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
// TTS Sync state
const [syncInfo, setSyncInfo] = useState<{
wordDuration: number;
audioDuration: number;
wordCount: number;
isHealthy: boolean;
warnings: string[];
} | null>(null);
// Auto-calculate TTS sync
useEffect(() => {
if (!ttsEnabled || !overlayText.trim()) {
setSyncInfo(null);
return;
}
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
ttsEngine,
ttsVoice,
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12);
// --- Added for Internal Audio Tracks ---
const { data: internalTracksResponse = [], isLoading: internalTracksLoading } = useQuery<any[]>({
queryKey: ['/api/audio-tracks'],
});
const internalTracks: MusicTrack[] = (internalTracksResponse || []).map(t => ({
id: `internal_${t.id}`,
title: t.title,
artist: t.fileName || t.title,
albumName: "Bibliothèque Interne",
duration: t.duration || 0,
previewUrl: t.url,
downloadUrl: t.url,
imageUrl: "",
license: "Internal"
}));
// --- End Internal Tracks ---
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
}
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedVideoId(data.id);
setSelectedVideo(data);
toast({ title: "Succès", description: "Vidéo téléchargée" });
},
onError: (error) => {
console.error("Upload error details:", error);
toast({
title: "Erreur Upload",
description: error.message || "Échec inattendu",
variant: "destructive",
duration: 5000
});
},
});
const generateTextMutation = useMutation({
mutationFn: async (productInfoText: string) => {
const response = await apiRequest('POST', '/api/reels/generate-text', {
productInfo: productInfoText,
});
return response.json();
},
onSuccess: (data: any) => {
setGeneratedVariants(data.variants || []);
toast({ title: "Généré", description: "Variations créées" });
},
});
const createReelMutation = useMutation({
mutationFn: async (data: any) => {
const response = await apiRequest('POST', '/api/reels', data);
return response.json();
},
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({
title: data.success ? "Reel créé !" : "Attention",
description: data.success ? "Publication réussie" : "Certaines erreurs",
});
navigate('/');
},
onError: (error: any) => {
toast({ title: "Erreur", description: error.message, variant: "destructive" });
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0 && acceptedFiles[0].type.startsWith('video/')) {
uploadMutation.mutate(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
accept: { "video/*": [".mp4", ".mov", ".webm"] },
noClick: true,
noKeyboard: true,
});
const togglePlayPreview = async (track: MusicTrack) => {
if (isPlaying === track.id) {
audioRef.current?.pause();
setIsPlaying(null);
} else {
if (audioRef.current) {
audioRef.current.src = track.previewUrl;
try {
await audioRef.current.play();
setIsPlaying(track.id);
} catch {
setIsPlaying(null);
}
}
}
};
const handleCreateReel = () => {
if (!selectedVideoId || selectedPages.length === 0) {
toast({ title: "Erreur", description: "Vidéo et page requises", variant: "destructive" });
return;
}
createReelMutation.mutate({
videoMediaId: selectedVideoId,
musicTrackId: selectedTrack?.id,
overlayText: overlayText,
description: overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
stabilize: stabilize,
drawText: true,
ttsEnabled,
ttsEngine,
ttsVoice,
enableEndingEffect,
});
};
const steps: Step[] = ['video', 'music', 'text', 'publish'];
const currentStepIndex = steps.indexOf(currentStep);
const goNext = () => {
if (currentStepIndex < steps.length - 1) setCurrentStep(steps[currentStepIndex + 1]);
};
const goBack = () => {
if (currentStepIndex > 0) setCurrentStep(steps[currentStepIndex - 1]);
};
return (
<div className="min-h-screen bg-background pb-20">
{/* Header Mobile */}
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="-ml-2">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[80%]">
<Sidebar />
</SheetContent>
</Sheet>
<h1 className="font-semibold text-lg">Nouveau Reel</h1>
</div>
<div className="text-sm text-muted-foreground font-medium">
{currentStepIndex + 1}/{steps.length}
</div>
</div>
<main className="p-4 space-y-6">
{/* VIDEO STEP */}
{currentStep === 'video' && (
<div className="space-y-4">
<div {...getRootProps()} className={`border-2 border-dashed rounded-xl p-6 text-center ${isDragActive ? 'border-primary bg-primary/5' : 'border-muted'}`}>
<input {...getInputProps()} />
<div className="flex justify-center mb-6">
<Button onClick={open} variant="outline" size="lg" className="h-12">
<Upload className="mr-2 h-5 w-5" /> Galerie
</Button>
</div>
{selectedVideo ? (
<div className="relative rounded-lg overflow-hidden aspect-[9/16] bg-black max-h-[50vh] mx-auto shadow-lg">
<video src={selectedVideo.originalUrl} className="w-full h-full object-contain" controls />
<div className="absolute top-2 right-2 bg-primary text-white text-xs px-2 py-1 rounded-full flex items-center shadow-sm">
<Check className="w-3 h-3 mr-1" /> Sélectionné
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3 mt-4">
{videoList.map((media) => (
<div key={media.id} onClick={() => { setSelectedVideoId(media.id); setSelectedVideo(media); }} className={`relative aspect-[9/16] bg-black rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id ? 'border-primary' : 'border-transparent'}`}>
<video src={media.originalUrl} className="w-full h-full object-cover" muted />
</div>
))}
</div>
)}
</div>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" /> Stabilisation Vidéo (Anti-tremblements)
</div>
<div className="text-xs text-muted-foreground">Corrige les secousses (Recommandé main levée). Désactiver sur trépied.</div>
</div>
<Switch
checked={stabilize}
onCheckedChange={(checked) => setStabilize(!!checked)}
/>
</div>
</CardContent>
</Card>
{selectedVideo && (
<Button className="w-full h-12 text-lg sticky bottom-4 shadow-xl" onClick={goNext}>
Suivant <ChevronRight className="ml-2 w-5 h-5" />
</Button>
)}
</div>
)}
{/* MUSIC STEP */}
{currentStep === 'music' && (
<div className="space-y-4">
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{selectedVideo && (
<div className="aspect-video bg-black rounded-lg overflow-hidden h-32 mx-auto">
<video src={selectedVideo.originalUrl} className="w-full h-full object-cover opacity-50" muted />
</div>
)}
<div className="space-y-2">
{internalTracksLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : internalTracks.length === 0 ? (
<div className="text-center py-10 border-2 border-dashed border-border rounded-xl">
<Music className="w-12 h-12 mx-auto text-muted-foreground mb-4 opacity-40" />
<p className="text-muted-foreground font-medium">Aucune musique disponible</p>
<p className="text-sm text-muted-foreground mt-1">Un administrateur peut ajouter des MP3 via la Bibliothèque Audio.</p>
</div>
) : (
internalTracks.map((track) => (
<div key={track.id} onClick={() => setSelectedTrack(track)} className={`flex items-center gap-3 p-3 rounded-xl border ${selectedTrack?.id === track.id ? 'border-primary bg-primary/5' : 'border-border bg-card'}`}>
<Button size="icon" variant="ghost" className="h-10 w-10 rounded-full bg-primary/10" onClick={(e) => { e.stopPropagation(); togglePlayPreview(track); }}>
{isPlaying === track.id ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-xs text-muted-foreground truncate">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{selectedTrack?.id === track.id && <Check className="h-5 w-5 text-primary" />}
</div>
))
)}
</div>
{selectedTrack && (
<div className="bg-card p-4 rounded-xl border mt-4">
<div className="flex items-center gap-2 mb-2">
<Volume2 className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">Volume: {musicVolume[0]}%</span>
</div>
<Slider value={musicVolume} onValueChange={setMusicVolume} max={100} step={5} />
</div>
)}
<div className="flex gap-3 sticky bottom-4">
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
</div>
</div>
)}
{/* TEXT STEP */}
{currentStep === 'text' && (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="Décrivez le produit..."
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
className="mb-3"
/>
<Button onClick={() => generateTextMutation.mutate(productInfo)} disabled={generateTextMutation.isPending} className="w-full" variant="secondary">
{generateTextMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Sparkles className="mr-2 w-4 h-4" />}
Générer
</Button>
</CardContent>
</Card>
{generatedVariants.length > 0 && (
<div className="space-y-3">
{generatedVariants.map((v, i) => (
<div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}>
{v.text}
</div>
))}
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Texte Overlay</label>
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Texte sur la vidéo..."
className="text-lg"
rows={3}
/>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="enable-ending-effect"
checked={enableEndingEffect}
onCheckedChange={setEnableEndingEffect}
/>
<Label htmlFor="enable-ending-effect" className="font-medium cursor-pointer">
Activer l'effet de fin (logo + fondu)
</Label>
</div>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="tts-mode"
checked={ttsEnabled}
onCheckedChange={setTtsEnabled}
/>
<Label htmlFor="tts-mode" className="font-medium cursor-pointer">
Activer la lecture voix (TTS)
</Label>
</div>
{ttsEnabled && (
<div className="mt-4 space-y-2 ml-1 p-3 bg-muted/30 rounded-lg border border-border/50">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mic className="w-4 h-4" />
<span>TTS — voix activée</span>
</div>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button
size="sm"
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('edge');
setTtsVoice('fr-FR-VivienneMultilingualNeural');
}}
>
Edge
</Button>
<Button
size="sm"
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => {
setTtsEngine('gemini');
setTtsVoice('fr-FR-Standard-B');
}}
>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2 mt-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
) : (
geminiVoices.map(v => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="mt-2">
<Button
size="sm"
variant="secondary"
className="w-full h-8 text-xs"
onClick={async (e) => {
e.stopPropagation();
const textToTest = overlayText || "Ceci est un test de voix.";
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`);
audio.play();
}
} catch (err) {
toast({ title: "Erreur", description: "Impossible de lire la voix", variant: "destructive" });
}
}}
>
<Play className="w-3 h-3 mr-1" /> Tester la voix
</Button>
</div>
{syncInfo && (
<div className={`p-2 rounded-lg border mt-2 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">Sync</span>
<span className={syncInfo.isHealthy ? 'text-green-600' : 'text-yellow-600'}>
{syncInfo.wordCount} mots · {syncInfo.audioDuration.toFixed(1)}s
</span>
</div>
{syncInfo.warnings.map((w, i) => (
<p key={i} className="text-[10px] text-yellow-600 mt-0.5">⚠️ {w}</p>
))}
{syncInfo.isHealthy && syncInfo.warnings.length === 0 && (
<p className="text-[10px] text-green-600 mt-0.5">✅ Timing OK</p>
)}
</div>
)}
</div>
)}
</div>
<div className="flex gap-3 sticky bottom-4">
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
</div>
</div>
)}
{/* PUBLISH STEP */}
{currentStep === 'publish' && (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pages.filter(p => p.platform === 'facebook').map((page) => (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
<Checkbox
id={page.id}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]);
else setSelectedPages(selectedPages.filter(id => id !== page.id));
}}
/>
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center"><Calendar className="mr-2 w-4 h-4" /> Planification</CardTitle>
</CardHeader>
<CardContent>
<DateTimePicker value={scheduledDate} onChange={setScheduledDate} placeholder="Maintenant" />
</CardContent>
</Card>
<div className="flex gap-3 sticky bottom-4 pb-4 bg-background">
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
<Button
onClick={handleCreateReel}
disabled={createReelMutation.isPending}
className="flex-[2] h-12 bg-primary text-primary-foreground font-bold shadow-lg"
>
{createReelMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Send className="mr-2 w-5 h-5" />}
{scheduledDate ? 'Programmer' : 'Publier'}
</Button>
</div>
</div>
)}
</main>
</div>
);
}
-520
View File
@@ -1,520 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
if (!expiresAt) {
return {
status: 'unknown' as const,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date();
const expiration = new Date(expiresAt);
const diffTime = expiration.getTime() - now.getTime();
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) {
return {
status: 'expired' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: 'Expiré'
};
} else if (daysLeft <= 7) {
return {
status: 'urgent' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
};
} else if (daysLeft <= 15) {
return {
status: 'warning' as const,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
daysLeft,
message: `${daysLeft} jours restants`
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
}
export default function PagesManagementMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
const deleteMutation = useMutation({
mutationFn: (pageId: string) =>
apiRequest('DELETE', `/api/pages/${pageId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page supprimée",
description: "La page a été retirée avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer la page",
variant: "destructive",
});
},
});
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Compact layout with single column */}
<div className="p-4 space-y-6 pb-24">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<p className="text-sm text-muted-foreground">
Connectez et gérez vos pages Facebook et Instagram
</p>
</div>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
{/* MOBILE: Stack buttons vertically */}
<div className="flex flex-col gap-3">
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/debug/accesstoken/', '_blank')}
className="w-full min-h-[48px]"
data-testid="button-debug-token"
>
<Bug className="w-5 h-5 mr-2" />
Débogueur de jeton
</Button>
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/explorer', '_blank')}
className="w-full min-h-[48px]"
data-testid="button-graph-explorer"
>
<Code className="w-5 h-5 mr-2" />
Graph Explorer
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 bg-card rounded-2xl border border-border/50 animate-pulse" />
))}
</div>
) : pages.length === 0 ? (
<Card className="border-dashed rounded-2xl border-border/50 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Plus className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2 text-center">Aucune page connectée</h3>
<p className="text-muted-foreground text-center mb-4 text-sm px-4">
Commencez par connecter vos pages Facebook et Instagram
</p>
<Button onClick={() => setDialogOpen(true)} className="min-h-[48px]" data-testid="button-add-first-page">
<Plus className="w-5 h-5 mr-2" />
Ajouter une page
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{pages.map((page) => (
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}>
<CardHeader className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
{page.platform === 'facebook' ? (
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Facebook className="w-6 h-6 text-white" />
</div>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Instagram className="w-6 h-6 text-white" />
</div>
)}
<div className="min-w-0">
<CardTitle className="text-base truncate">{page.pageName}</CardTitle>
<CardDescription className="capitalize text-xs">
{page.platform}
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-5 pt-0">
<div className="text-xs text-muted-foreground break-all">
ID: {page.pageId}
</div>
{/* Token expiration status */}
{(() => {
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
)}
</div>
);
})()}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
<Button
variant="destructive"
className="flex-1 min-h-[44px]"
onClick={() => deleteMutation.mutate(page.id)}
disabled={deleteMutation.isPending}
data-testid={`button-delete-page-${page.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Fixed Add Button */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t border-border shadow-lg">
<Button
onClick={() => setDialogOpen(true)}
className="w-full min-h-[52px]"
data-testid="button-add-page"
>
<Plus className="w-5 h-5 mr-2" />
Ajouter une page
</Button>
</div>
</div>
</main>
</div>
);
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState('');
const [pageId, setPageId] = useState('');
const [accessToken, setAccessToken] = useState('');
const { toast } = useToast();
const addMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('POST', '/api/pages', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page ajoutée",
description: "La page a été connectée avec succès",
});
onOpenChange(false);
setPlatform('facebook');
setPageName('');
setPageId('');
setAccessToken('');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible d'ajouter la page",
variant: "destructive",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addMutation.mutate({
platform,
pageName,
pageId,
accessToken,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Ajouter une page</DialogTitle>
<DialogDescription className="text-xs">
Connectez une page Facebook ou Instagram pour commencer à publier
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="platform" className="text-sm">Plateforme</Label>
<Select value={platform} onValueChange={(v) => setPlatform(v as any)}>
<SelectTrigger className="min-h-[48px]" data-testid="select-platform">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="facebook">Facebook</SelectItem>
<SelectItem value="instagram">Instagram</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pageName" className="text-sm">Nom de la page</Label>
<Input
id="pageName"
value={pageName}
onChange={(e) => setPageName(e.target.value)}
placeholder="Mon entreprise"
required
className="min-h-[48px]"
data-testid="input-page-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pageId" className="text-sm">ID de la page</Label>
<Input
id="pageId"
value={pageId}
onChange={(e) => setPageId(e.target.value)}
placeholder="123456789"
required
className="min-h-[48px]"
data-testid="input-page-id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accessToken" className="text-sm">Jeton d'accès</Label>
<Input
id="accessToken"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAxxxxxxxxxxxxx"
required
className="min-h-[48px]"
data-testid="input-access-token"
/>
<p className="text-xs text-muted-foreground">
Obtenez votre jeton depuis{' '}
<a
href="https://developers.facebook.com/tools/explorer"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Graph API Explorer
</a>
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
type="submit"
disabled={addMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-submit-page"
>
{addMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Ajout...
</>
) : (
'Ajouter'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="w-full min-h-[48px]"
data-testid="button-cancel"
>
Annuler
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState('');
const { toast } = useToast();
const editMutation = useMutation({
mutationFn: (data: any) =>
apiRequest('PUT', `/api/pages/${page?.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: "Page modifiée",
description: "Le jeton d'accès a été mis à jour avec succès",
});
onOpenChange(false);
setAccessToken('');
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de modifier la page",
variant: "destructive",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!page) return;
editMutation.mutate({
accessToken,
});
};
if (!page) return null;
return (
<Dialog open={!!page} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Modifier le jeton d'accès</DialogTitle>
<DialogDescription className="text-xs">
Mettez à jour le jeton d'accès pour {page.pageName}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="editAccessToken" className="text-sm">Nouveau jeton d'accès</Label>
<Input
id="editAccessToken"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAxxxxxxxxxxxxx"
required
className="min-h-[48px]"
data-testid="input-edit-access-token"
/>
<p className="text-xs text-muted-foreground">
Obtenez un nouveau jeton depuis{' '}
<a
href="https://developers.facebook.com/tools/explorer"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Graph API Explorer
</a>
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
type="submit"
disabled={editMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-submit-edit"
>
{editMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Modification...
</>
) : (
'Modifier'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="w-full min-h-[48px]"
data-testid="button-cancel-edit"
>
Annuler
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
-508
View File
@@ -1,508 +0,0 @@
import { useState, useRef } from "react";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
function stripForTTS(text: string) {
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
}
export default function MobileRemotionVideoPage() {
const [images, setImages] = useState<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [overlayText, setOverlayText] = useState("");
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const filesArray = Array.from(e.target.files);
const remaining = 4 - selectedLibraryImages.length - images.length;
// Corriger les noms de fichiers pour les fichiers venant de la galerie mobile
const processedFiles = filesArray.slice(0, remaining).map(file => {
if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') {
const ext = file.type.split('/')[1] || 'jpg';
return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type });
}
return file;
});
setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length));
}
// Reset input pour permettre de resélectionner les mêmes fichiers
e.target.value = '';
};
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const remaining = 4 - selectedLibraryImages.length - images.length;
if (remaining <= 0) {
toast({ title: "4 images max.", variant: "destructive" });
e.target.value = '';
return;
}
let fileToAdd = file;
if (!file.name || file.name === 'blob' || file.name === '') {
const ext = file.type.split('/')[1] || 'jpg';
fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type });
}
setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length));
e.target.value = '';
};
const toggleLibraryImage = (media: Media) => {
setSelectedLibraryImages(prev => {
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; }
return [...prev, media];
});
};
const totalSelected = images.length + selectedLibraryImages.length;
const handleTtsPreview = async () => {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
};
const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
};
const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
setIsRendering(true);
setVideoUrl(null);
try {
const formData = new FormData();
images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json();
if (data.jobId) {
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
const checkStatus = async () => {
try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
throw new Error("Erreur de suivi du rendu");
}
const job = await res.json();
if (job.status === 'done') {
setVideoUrl(job.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !" });
setIsRendering(false);
} else if (job.status === 'error') {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false);
} else {
setTimeout(checkStatus, 3000);
}
} catch (e: any) {
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
setIsRendering(false);
}
};
setTimeout(checkStatus, 3000);
} else {
setVideoUrl(data.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !" });
setIsRendering(false);
}
} catch (err: any) {
toast({ title: "Erreur", description: err.message, variant: "destructive" });
setIsRendering(false);
}
};
const handlePublish = async () => {
if (!videoUrl) return;
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
setIsPublishing(true);
try {
const response = await apiRequest('POST', '/api/remotion/publish', {
videoUrl, pageIds: selectedPageIds,
scheduledFor: scheduledDate?.toISOString(),
description: publishDescription || undefined,
});
const data = await response.json();
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
toast({
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
});
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
} catch (err: any) {
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
} finally { setIsPublishing(false); }
};
return (
<div className="min-h-screen bg-background pb-24">
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* Header */}
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
</Sheet>
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
</div>
<main className="p-4 space-y-4">
{/* 1. Images */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">1. Images</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Input caché pour la caméra */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
<input
ref={galleryInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
onClick={() => galleryInputRef.current?.click()}
disabled={totalSelected >= 4}
>
<ImagePlus className="h-6 w-6 text-primary" />
<span className="text-xs font-medium">Galerie</span>
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
onClick={() => cameraInputRef.current?.click()}
disabled={totalSelected >= 4}
>
<Camera className="h-6 w-6" />
<span className="text-xs">Photo</span>
</Button>
</div>
{imageMediaList.length > 0 && (
<div className="flex overflow-x-auto gap-2 pb-1">
{imageMediaList.map(media => {
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
return (
<div key={media.id} onClick={() => toggleLibraryImage(media)}
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
</div>
);
})}
</div>
)}
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
</CardContent>
</Card>
{/* 2. Texte & Voix */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
<Button variant="secondary" size="sm" className="w-full"
disabled={!productInfo || generateTextMutation.isPending}
onClick={() => generateTextMutation.mutate(productInfo)}>
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
Générer avec l'IA
</Button>
{generatedVariants.length > 0 && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{generatedVariants.map((v: any, i: number) => (
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
{v.text}
</div>
))}
</div>
)}
<Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<div className="flex items-center gap-3">
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
</div>
{ttsEnabled && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs px-2"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-B'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
</div>
)}
</CardContent>
</Card>
{/* 3. Musique */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="max-h-40 overflow-y-auto space-y-1.5">
{tracksLoading ? (
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
) : audioTracks.length === 0 ? (
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
</div>
) : (
audioTracks.map(track => {
const isSelected = selectedTrack?.id === track.id;
const playing = isPlaying === track.id;
return (
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{track.title}</p>
<p className="text-[10px] text-muted-foreground">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
);
})
)}
</div>
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
<input type="file" accept="audio/*"
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
</div>
{(musicFile || selectedTrack) && (
<div className="space-y-1">
<Label className="flex items-center gap-2 text-xs">
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
</Label>
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
</div>
)}
</CardContent>
</Card>
{/* 4. Page Facebook */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{facebookPages.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
) : (
facebookPages.map(page => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev =>
checked ? [...prev, page.id] : prev.filter(id => id !== page.id)
)
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
))
)}
</CardContent>
</Card>
{/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
{isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération (1-2 min)…</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button>
{videoUrl && (
<div className="space-y-3">
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
<Card className="border-primary/40">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Send className="w-4 h-4" /> Publier sur Facebook
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{facebookPages.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
) : selectedPageIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : (
<p className="text-xs text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
onChange={e => setPublishDescription(e.target.value)} />
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
occupiedDates={[]} placeholder="Publier immédiatement" />
<Button className="w-full" onClick={handlePublish}
disabled={isPublishing || selectedPageIds.length === 0}>
{isPublishing
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
</Button>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
-582
View File
@@ -1,582 +0,0 @@
import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X, Plug, Key } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox";
export default function SettingsMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState(true);
const [autoPublish, setAutoPublish] = useState(true);
const [cloudName, setCloudName] = useState("");
const [apiKey, setApiKey] = useState("");
const [apiSecret, setApiSecret] = useState("");
const [endpointUrl, setEndpointUrl] = useState("");
const [publicUrl, setPublicUrl] = useState("");
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [externalApiKey, setExternalApiKey] = useState("");
const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
queryKey: ["/api/auth/session"],
retry: false,
});
const isAdmin = session?.role === "admin";
const { data: cloudinaryConfig } = useQuery({
queryKey: ['/api/cloudinary/config'],
});
const { data: openrouterConfig } = useQuery({
queryKey: ['/api/openrouter/config'],
});
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'],
});
useEffect(() => {
if (cloudinaryConfig) {
setCloudName((cloudinaryConfig as any).cloudName || "");
setApiKey((cloudinaryConfig as any).apiKey || "");
setEndpointUrl((cloudinaryConfig as any).endpointUrl || "");
setPublicUrl((cloudinaryConfig as any).publicUrl || "");
if ((cloudinaryConfig as any).logoUrl) {
setLogoPreview((cloudinaryConfig as any).logoUrl);
}
}
}, [cloudinaryConfig]);
useEffect(() => {
if (openrouterConfig) {
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
}
}, [openrouterConfig]);
const hasExistingConfig = !!openrouterConfig;
const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'],
enabled: isAdmin,
});
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
const saveExternalApiMutation = useMutation({
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
setExternalApiKey("");
toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" });
},
onError: () => {
toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" });
},
});
const deleteExternalApiMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" });
},
onError: () => {
toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" });
},
});
const saveCloudinaryMutation = useMutation({
mutationFn: () => {
const payload: any = {
cloudName,
endpointUrl: endpointUrl || undefined,
publicUrl: publicUrl || undefined,
};
if (apiKey && apiKey.trim() !== "") {
payload.apiKey = apiKey;
}
if (apiSecret && apiSecret.trim() !== "") {
payload.apiSecret = apiSecret;
}
return apiRequest('POST', '/api/cloudinary/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({
title: "Configuration sauvegardée",
description: "Vos paramètres Cloudinary ont été enregistrés",
});
setApiKey("");
setApiSecret("");
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration",
variant: "destructive",
});
},
});
const saveOpenrouterMutation = useMutation({
mutationFn: () => {
const payload: any = {
model: openrouterModel,
systemPrompt: openrouterSystemPrompt,
};
if (openrouterApiKey && openrouterApiKey.trim() !== "") {
payload.apiKey = openrouterApiKey;
}
return apiRequest('POST', '/api/openrouter/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/openrouter/config'] });
toast({
title: "Configuration sauvegardée",
description: "Vos paramètres OpenRouter ont été enregistrés",
});
setOpenrouterApiKey("");
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration OpenRouter",
variant: "destructive",
});
},
});
const uploadLogoMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('logo', file);
const response = await fetch('/api/cloudinary/logo', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({
title: "Logo uploadé",
description: "Votre logo a été enregistré avec succès",
});
setLogoFile(null);
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message || "Impossible d'uploader le logo",
variant: "destructive",
});
},
});
const deleteLogoMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
setLogoPreview(null);
setLogoFile(null);
toast({
title: "Logo supprimé",
description: "Votre logo a été supprimé avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer le logo",
variant: "destructive",
});
},
});
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: "Erreur",
description: "Veuillez sélectionner une image",
variant: "destructive",
});
return;
}
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
<p className="text-sm text-muted-foreground">
Configurez votre application
</p>
</div>
{/* Notifications */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="w-5 h-5" />
Notifications
</CardTitle>
<CardDescription className="text-xs">
Gérez vos préférences de notification
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="space-y-0.5 flex-1 pr-3">
<Label htmlFor="notifications" className="text-sm">Notifications activées</Label>
<p className="text-xs text-muted-foreground">
Recevez des notifications pour les publications
</p>
</div>
<Switch
id="notifications"
checked={notifications}
onCheckedChange={setNotifications}
data-testid="switch-notifications"
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="space-y-0.5 flex-1 pr-3">
<Label htmlFor="autoPublish" className="text-sm">Publication automatique</Label>
<p className="text-xs text-muted-foreground">
Publier automatiquement à l'heure programmée
</p>
</div>
<Switch
id="autoPublish"
checked={autoPublish}
onCheckedChange={setAutoPublish}
data-testid="switch-auto-publish"
/>
</div>
</CardContent>
</Card>
{/* OpenRouter */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="w-5 h-5" />
Configuration OpenRouter (IA)
</CardTitle>
<CardDescription className="text-xs">
Configurez OpenRouter pour la génération de texte avec l'IA
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<div className="space-y-2">
<Label htmlFor="openrouterApiKey" className="text-sm">Clé API OpenRouter</Label>
<Input
id="openrouterApiKey"
type="password"
value={openrouterApiKey}
onChange={(e) => setOpenrouterApiKey(e.target.value)}
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
className="min-h-[48px]"
data-testid="input-openrouter-api-key"
/>
<p className="text-xs text-muted-foreground">
{hasExistingConfig ? (
<>
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
</>
) : (
<>
Obtenez votre clé API sur{" "}
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="underline">
openrouter.ai
</a>
</>
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouterModel" className="text-sm">Modèle d'IA</Label>
<ModelCombobox
models={(openrouterModels as any)?.data || []}
value={openrouterModel}
onValueChange={setOpenrouterModel}
placeholder="Sélectionner un modèle"
isLoading={modelsLoading}
className="w-full"
testId="select-openrouter-model"
/>
<p className="text-xs text-muted-foreground">
Choisissez le modèle d'IA pour générer vos publications
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouterSystemPrompt" className="text-sm">Prompt système</Label>
<Textarea
id="openrouterSystemPrompt"
value={openrouterSystemPrompt}
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
placeholder="Instructions pour l'IA..."
rows={5}
data-testid="textarea-openrouter-system-prompt"
/>
<p className="text-xs text-muted-foreground">
Définissez les instructions que l'IA doit suivre pour générer vos textes
</p>
</div>
<Button
onClick={() => saveOpenrouterMutation.mutate()}
disabled={
saveOpenrouterMutation.isPending ||
!openrouterModel ||
!openrouterSystemPrompt ||
(!hasExistingConfig && !openrouterApiKey)
}
data-testid="button-save-openrouter"
className="w-full min-h-[48px]"
>
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
</Button>
</CardContent>
</Card>
{/* Stockage local */}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="w-5 h-5" />
Stockage des médias
</CardTitle>
<CardDescription className="text-xs">
Les médias sont stockés localement sur le serveur
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
<div className="rounded-lg bg-muted p-4 space-y-1 text-sm text-muted-foreground">
<p>✓ Images et vidéos stockées sur le disque du serveur</p>
<p>✓ Vidéos supprimées automatiquement après publication</p>
<p>✓ Images supprimées automatiquement après 30 jours</p>
<p>✓ Vidéos non publiées supprimées après 7 jours</p>
</div>
</CardContent>
</Card>
)}
{/* Logo */}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Image className="w-5 h-5" />
Logo de l'entreprise
</CardTitle>
<CardDescription className="text-xs">
Uploadez votre logo pour l'ajouter aux images éditées
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
{logoPreview ? (
<div className="space-y-4">
<div className="relative w-full max-w-xs mx-auto">
<img
src={logoPreview}
alt="Logo"
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
/>
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-10 w-10"
onClick={() => deleteLogoMutation.mutate()}
disabled={deleteLogoMutation.isPending}
data-testid="button-delete-logo"
>
<X className="h-5 w-5" />
</Button>
</div>
<div>
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
<Button
variant="outline"
className="w-full min-h-[48px]"
disabled={uploadLogoMutation.isPending}
data-testid="button-replace-logo"
onClick={() => document.getElementById('logo-upload-replace')?.click()}
>
<Upload className="w-5 h-5 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
</Button>
</Label>
<input
id="logo-upload-replace"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucun logo uploadé. Choisissez une image PNG ou JPG.
</p>
<Label htmlFor="logo-upload" className="cursor-pointer">
<Button
variant="outline"
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
className="min-h-[48px]"
data-testid="button-upload-logo"
onClick={() => document.getElementById('logo-upload')?.click()}
>
<Upload className="w-5 h-5 mr-2" />
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
</Button>
</Label>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
handleLogoChange(e);
if (e.target.files?.[0]) {
uploadLogoMutation.mutate(e.target.files[0]);
}
}}
/>
</div>
{!cloudinaryConfig && (
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
Veuillez d'abord configurer Cloudinary ci-dessus
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* API externe */}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<Plug className="w-5 h-5" />
API externe
</CardTitle>
<CardDescription className="text-xs">
Clé d'accès pour créer et programmer des publications via l'API externe
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
{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é active</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteExternalApiMutation.mutate()}
disabled={deleteExternalApiMutation.isPending}
>
{deleteExternalApiMutation.isPending ? "..." : "Révoquer"}
</Button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="externalApiKeyMobile">
{hasExistingExternalApiConfig ? "Nouvelle clé (remplace l'actuelle)" : "Clé API"}
</Label>
<Input
id="externalApiKeyMobile"
type="password"
value={externalApiKey}
onChange={(e) => setExternalApiKey(e.target.value)}
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"}
className="min-h-[48px]"
/>
<p className="text-xs text-muted-foreground">
Header <code className="bg-muted px-1 rounded">X-API-Key</code> dans vos requêtes
</p>
</div>
<Button
onClick={() => saveExternalApiMutation.mutate()}
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
className="w-full min-h-[48px]"
>
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
</Button>
</CardContent>
</Card>
)}
</div>
</main>
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Database, Download, Play } from "lucide-react";
export default function SqlAdminMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
const [sqlQuery, setSqlQuery] = useState("");
const [queryResult, setQueryResult] = useState<any>(null);
const { data: tablesData } = useQuery({
queryKey: ["/api/sql/tables"],
});
const executeSqlMutation = useMutation({
mutationFn: async (query: string) => {
const res = await apiRequest("POST", "/api/sql/execute", { query });
return await res.json();
},
onSuccess: (data) => {
setQueryResult(data);
if (data.success) {
toast({
title: "Requête exécutée",
description: "La requête SQL a été exécutée avec succès",
});
}
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur SQL",
description: error.message || "Erreur lors de l'exécution de la requête",
});
},
});
const handleExecuteQuery = () => {
if (!sqlQuery.trim()) {
toast({
variant: "destructive",
title: "Erreur",
description: "Veuillez entrer une requête SQL",
});
return;
}
executeSqlMutation.mutate(sqlQuery);
};
const handleDownloadResult = () => {
if (!queryResult?.result) return;
const json = JSON.stringify(queryResult.result, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `sql-result-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const insertTable = (tableName: string) => {
setSqlQuery(`SELECT * FROM ${tableName} LIMIT 10;`);
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Database className="w-7 h-7" />
Administration SQL
</h1>
<p className="text-sm text-muted-foreground">
Exécutez des requêtes SQL et gérez votre base de données
</p>
</div>
{/* Tables disponibles */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Tables disponibles</CardTitle>
<CardDescription className="text-xs">
Cliquez pour insérer dans la requête
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
<div className="grid grid-cols-2 gap-2">
{(tablesData as any)?.tables?.map((table: any) => (
<Button
key={table.tablename}
variant="outline"
className="justify-start text-left min-h-[44px] text-xs"
onClick={() => insertTable(table.tablename)}
data-testid={`button-table-${table.tablename}`}
>
<Database className="w-4 h-4 mr-2 flex-shrink-0" />
<span className="truncate">{table.tablename}</span>
</Button>
))}
</div>
</CardContent>
</Card>
{/* Éditeur SQL */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Éditeur SQL</CardTitle>
<CardDescription className="text-xs">
Entrez votre requête SQL ci-dessous
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-5 pt-0">
<Textarea
placeholder="SELECT * FROM users LIMIT 10;"
value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)}
className="font-mono min-h-[150px] text-sm"
data-testid="textarea-sql-query"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleExecuteQuery}
disabled={executeSqlMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-execute-sql"
>
<Play className="w-5 h-5 mr-2" />
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
</Button>
<Button
variant="outline"
onClick={() => setSqlQuery("")}
className="w-full min-h-[48px]"
data-testid="button-clear-sql"
>
Effacer
</Button>
</div>
</CardContent>
</Card>
{/* Résultats */}
{queryResult && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<div className="flex flex-col gap-3">
<div>
<CardTitle className="text-base">Résultats</CardTitle>
<CardDescription className="text-xs">
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"}
</CardDescription>
</div>
{queryResult.success && queryResult.result && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadResult}
className="w-full min-h-[44px]"
data-testid="button-download-result"
>
<Download className="w-4 h-4 mr-2" />
Télécharger JSON
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-5 pt-0">
{queryResult.success ? (
<div className="overflow-x-auto">
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
<div className="space-y-3">
{queryResult.result.map((row: any, idx: number) => (
<div key={idx} className="p-3 rounded-lg border bg-card space-y-2">
<div className="text-xs font-semibold text-muted-foreground">Ligne {idx + 1}</div>
{Object.entries(row).map(([key, value]: [string, any]) => (
<div key={key} className="flex flex-col gap-1">
<div className="text-xs font-medium">{key}</div>
<div className="font-mono text-xs text-muted-foreground break-all">
{value === null ? (
<span className="italic">null</span>
) : typeof value === 'object' ? (
JSON.stringify(value)
) : (
String(value)
)}
</div>
</div>
))}
</div>
))}
</div>
) : (
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96 text-xs">
<code>{JSON.stringify(queryResult.result, null, 2)}</code>
</pre>
)}
</div>
) : (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg">
<p className="font-semibold text-sm">Erreur :</p>
<p className="font-mono text-xs mt-2 break-all">{queryResult.error}</p>
</div>
)}
</CardContent>
</Card>
)}
</div>
</main>
</div>
);
}
-589
View File
@@ -1,589 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, Settings } from "lucide-react";
type UserData = {
id: string;
username: string;
role: "admin" | "user";
};
type SocialPage = {
id: string;
pageName: string;
platform: string;
userId: string;
};
type UserPagePermission = {
id: string;
userId: string;
pageId: string;
};
export default function UsersAdminMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserData | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editPassword, setEditPassword] = useState("");
const [editRole, setEditRole] = useState<"admin" | "user">("user");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const [permissionsUser, setPermissionsUser] = useState<UserData | null>(null);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const { data: users, isLoading } = useQuery<UserData[]>({
queryKey: ["/api/users"],
});
const { data: allPages } = useQuery<SocialPage[]>({
queryKey: ["/api/pages"],
});
const { data: userPermissions } = useQuery<UserPagePermission[]>({
queryKey: ["/api/users", permissionsUser?.id, "page-permissions"],
enabled: !!permissionsUser && permissionsDialogOpen,
});
const createUserMutation = useMutation({
mutationFn: async (userData: { username: string; password: string; role: string }) => {
const res = await apiRequest("POST", "/api/users", userData);
return await res.json();
},
onSuccess: (data) => {
toast({
title: "Utilisateur créé",
description: `L'utilisateur ${data.username} a été créé avec succès`,
});
setUsername("");
setPassword("");
setRole("user");
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la création de l'utilisateur",
});
},
});
const updateUserMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const res = await apiRequest("PATCH", `/api/users/${id}`, data);
return await res.json();
},
onSuccess: (data) => {
toast({
title: "Utilisateur modifié",
description: `L'utilisateur ${data.username} a été modifié avec succès`,
});
setEditDialogOpen(false);
setEditingUser(null);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la modification de l'utilisateur",
});
},
});
const deleteUserMutation = useMutation({
mutationFn: async (id: string) => {
const res = await apiRequest("DELETE", `/api/users/${id}`, {});
return await res.json();
},
onSuccess: () => {
toast({
title: "Utilisateur supprimé",
description: "L'utilisateur a été supprimé avec succès",
});
setDeleteDialogOpen(false);
setUserToDelete(null);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la suppression de l'utilisateur",
});
},
});
const updatePermissionsMutation = useMutation({
mutationFn: async ({ userId, pageIds }: { userId: string; pageIds: string[] }) => {
const res = await apiRequest("POST", `/api/users/${userId}/page-permissions`, { pageIds });
return await res.json();
},
onSuccess: () => {
toast({
title: "Permissions mises à jour",
description: "Les permissions de l'utilisateur ont été mises à jour avec succès",
});
setPermissionsDialogOpen(false);
setPermissionsUser(null);
setSelectedPageIds([]);
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Erreur",
description: error.message || "Erreur lors de la mise à jour des permissions",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
toast({
variant: "destructive",
title: "Erreur",
description: "Veuillez remplir tous les champs",
});
return;
}
if (password.length < 4) {
toast({
variant: "destructive",
title: "Erreur",
description: "Le mot de passe doit contenir au moins 4 caractères",
});
return;
}
createUserMutation.mutate({ username, password, role });
};
const handleEditClick = (user: UserData) => {
setEditingUser(user);
setEditUsername(user.username);
setEditPassword("");
setEditRole(user.role);
setEditDialogOpen(true);
};
const handleEditSubmit = () => {
if (!editingUser) return;
const updateData: any = {};
if (editUsername !== editingUser.username) {
updateData.username = editUsername;
}
if (editPassword) {
updateData.password = editPassword;
}
if (editRole !== editingUser.role) {
updateData.role = editRole;
}
if (Object.keys(updateData).length === 0) {
toast({
variant: "destructive",
title: "Erreur",
description: "Aucune modification détectée",
});
return;
}
updateUserMutation.mutate({ id: editingUser.id, data: updateData });
};
const handleDeleteClick = (user: UserData) => {
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = () => {
if (userToDelete) {
deleteUserMutation.mutate(userToDelete.id);
}
};
const handlePermissionsClick = (user: UserData) => {
setPermissionsUser(user);
setPermissionsDialogOpen(true);
};
useEffect(() => {
if (userPermissions) {
setSelectedPageIds(userPermissions.map(p => p.pageId));
}
}, [userPermissions]);
const handlePermissionsSubmit = () => {
if (permissionsUser) {
updatePermissionsMutation.mutate({
userId: permissionsUser.id,
pageIds: selectedPageIds,
});
}
};
const togglePagePermission = (pageId: string) => {
setSelectedPageIds(prev => {
if (prev.includes(pageId)) {
return prev.filter(id => id !== pageId);
} else {
return [...prev, pageId];
}
});
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
{/* MOBILE: Single column layout */}
<div className="p-4 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Users className="w-7 h-7" />
Gestion des utilisateurs
</h1>
<p className="text-sm text-muted-foreground">
Créez et gérez les utilisateurs de l'application
</p>
</div>
{/* Formulaire de création */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="flex items-center gap-2 text-base">
<UserPlus className="w-5 h-5" />
Créer un utilisateur
</CardTitle>
<CardDescription className="text-xs">
Ajouter un nouvel utilisateur
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">Nom d'utilisateur</Label>
<Input
id="username"
type="text"
placeholder="utilisateur123"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="min-h-[48px]"
data-testid="input-new-username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">Mot de passe</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="min-h-[48px]"
data-testid="input-new-password"
/>
<p className="text-xs text-muted-foreground">
Minimum 4 caractères
</p>
</div>
<div className="space-y-2">
<Label htmlFor="role" className="text-sm">Rôle</Label>
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
<SelectTrigger id="role" className="min-h-[48px]" data-testid="select-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="submit"
className="w-full min-h-[48px]"
disabled={createUserMutation.isPending}
data-testid="button-create-user"
>
{createUserMutation.isPending ? "Création..." : "Créer"}
</Button>
</form>
</CardContent>
</Card>
{/* Liste des utilisateurs */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-5">
<CardTitle className="text-base">Liste des utilisateurs</CardTitle>
<CardDescription className="text-xs">
{users?.length || 0} utilisateur(s) enregistré(s)
</CardDescription>
</CardHeader>
<CardContent className="p-5 pt-0">
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-4 text-sm">Chargement...</p>
</div>
) : users && users.length > 0 ? (
<div className="space-y-3">
{users.map((user) => (
<div key={user.id} className="p-4 rounded-lg border bg-card space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{user.role === "admin" ? (
<Shield className="w-5 h-5 text-primary flex-shrink-0" />
) : (
<UserIcon className="w-5 h-5 text-muted-foreground flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{user.username}</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="mt-1">
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
</Badge>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
{user.role === "user" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePermissionsClick(user)}
className="w-full min-h-[44px]"
data-testid={`button-permissions-user-${user.id}`}
>
<Settings className="w-4 h-4 mr-2" />
Permissions
</Button>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditClick(user)}
className="flex-1 min-h-[44px]"
data-testid={`button-edit-user-${user.id}`}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick(user)}
className="flex-1 min-h-[44px]"
data-testid={`button-delete-user-${user.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground text-sm">Aucun utilisateur trouvé</p>
</div>
)}
</CardContent>
</Card>
</div>
</main>
{/* Dialog de modification */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="w-[95vw] max-w-md mx-auto">
<DialogHeader>
<DialogTitle>Modifier l'utilisateur</DialogTitle>
<DialogDescription className="text-xs">
Modifiez les informations de l'utilisateur. Laissez le mot de passe vide pour ne pas le changer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-username" className="text-sm">Nom d'utilisateur</Label>
<Input
id="edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
className="min-h-[48px]"
data-testid="input-edit-username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-password" className="text-sm">Nouveau mot de passe (optionnel)</Label>
<Input
id="edit-password"
type="password"
placeholder="Laisser vide pour ne pas changer"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className="min-h-[48px]"
data-testid="input-edit-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role" className="text-sm">Rôle</Label>
<Select value={editRole} onValueChange={(value: "admin" | "user") => setEditRole(value)}>
<SelectTrigger id="edit-role" className="min-h-[48px]" data-testid="select-edit-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex-col gap-2">
<Button
onClick={handleEditSubmit}
disabled={updateUserMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-save-edit"
>
{updateUserMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button variant="outline" onClick={() => setEditDialogOpen(false)} className="w-full min-h-[48px]">
Annuler
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de confirmation de suppression */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="w-[95vw] max-w-md mx-auto">
<AlertDialogHeader>
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
<AlertDialogDescription className="text-xs">
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
Cette action est irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col gap-2">
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 w-full min-h-[48px]"
data-testid="button-confirm-delete"
>
{deleteUserMutation.isPending ? "Suppression..." : "Supprimer"}
</AlertDialogAction>
<AlertDialogCancel className="w-full min-h-[48px] mt-0">Annuler</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog de gestion des permissions */}
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
<DialogContent className="w-[95vw] max-w-2xl mx-auto max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription className="text-xs">
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
</DialogDescription>
</DialogHeader>
<div className="py-4">
{allPages && allPages.length > 0 ? (
<div className="space-y-3">
{allPages.map((page) => (
<div key={page.id} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-accent touch-manipulation">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={() => togglePagePermission(page.id)}
className="w-5 h-5"
data-testid={`checkbox-page-${page.id}`}
/>
<Label
htmlFor={`page-${page.id}`}
className="flex-1 cursor-pointer"
>
<div className="font-medium text-sm">{page.pageName}</div>
<div className="text-xs text-muted-foreground">
{page.platform === "facebook" ? "Facebook" : "Instagram"}
</div>
</Label>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
Aucune page disponible
</div>
)}
</div>
<DialogFooter className="flex-col gap-2">
<Button
onClick={handlePermissionsSubmit}
disabled={updatePermissionsMutation.isPending}
className="w-full min-h-[48px]"
data-testid="button-save-permissions"
>
{updatePermissionsMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button variant="outline" onClick={() => setPermissionsDialogOpen(false)} className="w-full min-h-[48px]">
Annuler
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+389 -225
View File
@@ -1,7 +1,16 @@
import { useState, useCallback, useRef, useMemo } from "react"; import { useState, useCallback, useRef, useMemo } from "react";
import { Page } from "@/components/layout/app-shell";
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,
@@ -20,20 +29,16 @@ import {
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
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 } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -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'
}`} }`}
> >
@@ -80,20 +85,21 @@ function SortableMediaItem({
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
</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>
</> </>
)} )}
@@ -101,8 +107,32 @@ function SortableMediaItem({
); );
} }
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
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 [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
@@ -115,13 +145,21 @@ 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');
const { data: pages = [] } = useQuery<SocialPage[]>({ // Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
const pages = allPages.filter(p => p.platform !== 'tiktok');
// Seuls les douze derniers médias sont proposés ici : autant ne demander
// que ceux-là plutôt que toute la médiathèque.
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
}); });
// Afficher seulement les 12 derniers médias triés par date décroissante // Afficher seulement les 12 derniers médias triés par date décroissante
@@ -134,13 +172,9 @@ export default function NewPost() {
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA; // Tri décroissant (plus récent en premier) return dateB - dateA; // Tri décroissant (plus récent en premier)
}) })
.slice(0, 12); .slice(0, RECENT_MEDIA_COUNT);
}, [allMedia]); }, [allMedia]);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'],
});
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
const formData = new FormData(); const formData = new FormData();
@@ -223,10 +257,10 @@ export default function NewPost() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -304,10 +338,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>) => {
@@ -366,49 +396,81 @@ 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 (
<div className="flex h-screen overflow-hidden bg-background"> <>
{sidebarOpen && ( <Page width="default">
<div <PageHeader
className="fixed inset-0 bg-black/50 z-40 lg:hidden" icon={PenSquare}
onClick={() => setSidebarOpen(false)} title="Nouvelle publication"
description="Préparez une publication photo ou vidéo pour Facebook et Instagram."
/> />
)}
<div className={` <Stepper steps={STEPS} current={step} lockedReason={lockedReason} onStepClick={goTo} />
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} /> <div className="min-w-0 lg:col-span-2">
<input
ref={cameraInputRef}
type="file"
accept="image/*,video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto"> {/* ÉTAPE 1 : Médias */}
<div className="mb-8"> {step === 0 && (
<h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1> <Card className="fade-in">
<p className="text-muted-foreground mt-2"> <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
Créez et planifiez une nouvelle publication
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 lg:gap-8">
<div className="space-y-8">
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<div className="flex items-center justify-between">
<div> <div>
<CardTitle>Média</CardTitle> <CardTitle>Choisissez vos médias</CardTitle>
<CardDescription>Sélectionnez ou uploadez une image/vidéo</CardDescription> <CardDescription>Jusqu'à 10 photos ou vidéos. Facultatif pour une publication texte.</CardDescription>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 gap-2">
<Button <Button
onClick={() => cameraInputRef.current?.click()} onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
size="sm" size="icon"
variant="outline" variant="outline"
className="lg:hidden" className="lg:hidden"
aria-label="Prendre une photo"
data-testid="button-camera-capture" data-testid="button-camera-capture"
> >
<Camera className="w-4 h-4" /> <Camera className="w-4 h-4" />
@@ -420,30 +482,25 @@ export default function NewPost() {
variant="outline" variant="outline"
data-testid="button-upload-new-media" data-testid="button-upload-new-media"
> >
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4" />
{uploadMutation.isPending ? 'Upload...' : 'Uploader'} Importer
</Button> </Button>
</div> </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={
<div className="flex flex-wrap justify-center gap-2">
<Button <Button
onClick={() => cameraInputRef.current?.click()} onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
@@ -452,7 +509,7 @@ export default function NewPost() {
className="lg:hidden" className="lg:hidden"
data-testid="button-camera-first" data-testid="button-camera-first"
> >
<Camera className="w-4 h-4 mr-2" /> <Camera className="w-4 h-4" />
Prendre une photo Prendre une photo
</Button> </Button>
<Button <Button
@@ -461,17 +518,19 @@ export default function NewPost() {
size="sm" size="sm"
data-testid="button-upload-first-media" data-testid="button-upload-first-media"
> >
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4" />
Uploader une photo Importer un fichier
</Button> </Button>
</div> </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 +541,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,168 +563,177 @@ 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>
<div className="max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{mediaList.map((media) => { {mediaList.map((media) => {
const isSelected = selectedMedia.includes(media.id); const isSelected = selectedMedia.includes(media.id);
const isVideo = media.type === 'video'; const isVideo = media.type === 'video';
const order = selectedMedia.indexOf(media.id);
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)
setSelectedMedia(prev => [...prev, media.id]);
} else {
toast({
title: "Limite atteinte",
description: "Maximum 10 photos par publication",
variant: "destructive",
});
}
}}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
? 'border-primary ring-2 ring-primary opacity-50'
: 'border-transparent hover:border-muted-foreground'
}`} }`}
aria-pressed={isSelected}
data-testid={`button-select-media-${media.id}`} data-testid={`button-select-media-${media.id}`}
> >
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
{isSelected && ( {isSelected && (
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center"> <div className="absolute inset-0 bg-primary/20">
<div className="text-xs font-semibold text-primary">Sélectionnée</div> <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">
{order + 1}
</span>
</div> </div>
)} )}
</button> </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>
<CardTitle>Rédigez votre texte</CardTitle>
<CardDescription>Écrivez-le vous-même ou laissez l'IA vous proposer des versions.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <Collapsible open={aiOpen} onOpenChange={setAiOpen} className="rounded-xl border border-primary/20 bg-primary/5">
<Label htmlFor="productInfo">Informations du produit</Label> <CollapsibleTrigger className="flex w-full items-center gap-3 p-3 text-left" data-testid="toggle-ai-assist">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</span>
<span className="flex-1">
<span className="block text-sm font-medium">Générer avec l'IA</span>
<span className="block text-xs text-muted-foreground">Décrivez votre produit, l'IA rédige 3 propositions</span>
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${aiOpen ? 'rotate-180' : ''}`} />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 px-3 pb-3">
<Textarea <Textarea
id="productInfo" id="productInfo"
value={productInfo} value={productInfo}
onChange={(e) => setProductInfo(e.target.value)} onChange={(e) => setProductInfo(e.target.value)}
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc." placeholder="Ex. : Produit : Chaise en rotin — Prix : 49 € — Caractéristiques : légère, résistante"
rows={6} rows={4}
className="bg-card"
data-testid="input-product-info" data-testid="input-product-info"
/> />
</div>
<Button <Button
onClick={handleGenerateText} onClick={handleGenerateText}
disabled={generateTextMutation.isPending} disabled={generateTextMutation.isPending}
className="w-full" className="w-full sm:w-auto"
data-testid="button-generate-text" data-testid="button-generate-text"
> >
<Sparkles className="w-4 h-4 mr-2" /> {generateTextMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'} {generateTextMutation.isPending ? 'Génération…' : 'Générer des propositions'}
</Button> </Button>
</CardContent>
</Card>
{generatedVariants.length > 0 && ( {generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg"> <div className="space-y-2 pt-1">
<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>
<CardContent className="space-y-3">
{generatedVariants.map((variant, index) => ( {generatedVariants.map((variant, index) => (
<div <div key={index} className="rounded-lg border bg-card p-3">
key={index} <div className="mb-1.5 flex items-center justify-between gap-2">
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors" <span className="text-xs font-semibold text-muted-foreground">
> {variant.variant || `Proposition ${index + 1}`}
<div className="flex items-start justify-between gap-3"> </span>
<div className="flex-1">
<div className="text-xs font-semibold text-muted-foreground mb-2">
{variant.variant || `Version ${index + 1}`}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{variant.text}
</p>
</div>
<Button <Button
size="sm" size="sm"
variant={postText === variant.text ? "secondary" : "default"}
className="h-8"
onClick={() => handleUseVariant(variant.text)} onClick={() => handleUseVariant(variant.text)}
data-testid={`button-use-variant-${index}`} data-testid={`button-use-variant-${index}`}
> >
Utiliser {postText === variant.text ? <><Check className="h-3.5 w-3.5" /> Utilisée</> : "Utiliser"}
</Button> </Button>
</div> </div>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{variant.text}</p>
</div> </div>
))} ))}
</CardContent> </div>
</Card>
)} )}
</CollapsibleContent>
</Collapsible>
<Card className="rounded-2xl border-border/50 shadow-lg"> <div className="space-y-2">
<CardHeader className="p-6"> <div className="flex items-center justify-between">
<CardTitle>Texte de la publication</CardTitle> <Label htmlFor="post-text">Texte de la publication</Label>
<CardDescription>Écrivez ou générez le texte de votre publication</CardDescription> <span className="text-xs tabular-nums text-muted-foreground">{postText.length} caractères</span>
</CardHeader> </div>
<CardContent>
<Textarea <Textarea
id="post-text"
value={postText} value={postText}
onChange={(e) => setPostText(e.target.value)} onChange={(e) => setPostText(e.target.value)}
placeholder="Écrivez votre texte ici ou générez-le avec l'IA..." placeholder="Écrivez votre texte ici…"
rows={8} rows={9}
data-testid="textarea-post-text" 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> </div>
)}
<div className="space-y-8"> {/* ÉTAPE 3 : Diffusion */}
<Card className="rounded-2xl border-border/50 shadow-lg"> {step === 2 && (
<CardHeader className="p-6"> <div className="fade-in space-y-6">
<CardTitle>Pages cibles</CardTitle> <Card>
<CardDescription>Sélectionnez les pages où publier</CardDescription> <CardHeader>
<CardTitle>Où publier ?</CardTitle>
<CardDescription>Sélectionnez une ou plusieurs pages.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{pages.length === 0 ? ( {pages.length === 0 ? (
<div className="text-center py-8"> <EmptyState
<p className="text-muted-foreground mb-2">Aucune page connectée</p> compact
<Button icon={Link2}
variant="link" title="Aucune page connectée"
onClick={() => navigate('/pages')} description="Connectez une page Facebook ou Instagram pour pouvoir publier."
data-testid="link-add-pages" action={
> <Button size="sm" onClick={() => navigate('/pages')} data-testid="link-add-pages">
Ajouter des pages Connecter un compte
</Button> </Button>
</div> }
/>
) : ( ) : (
<div className="space-y-3"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{pages.map((page) => ( {pages.map((page) => {
<div key={page.id} className="flex items-center space-x-2"> 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 <Checkbox
id={`page-${page.id}`} id={`page-${page.id}`}
checked={selectedPages.includes(page.id)} checked={checked}
onCheckedChange={(checked) => { onCheckedChange={(value) => {
if (checked) { if (value) {
setSelectedPages([...selectedPages, page.id]); setSelectedPages([...selectedPages, page.id]);
} else { } else {
setSelectedPages(selectedPages.filter(id => id !== page.id)); setSelectedPages(selectedPages.filter(id => id !== page.id));
@@ -673,83 +741,181 @@ export default function NewPost() {
}} }}
data-testid={`checkbox-page-${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> </label>
</div> );
))} })}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card>
<CardHeader className="p-6"> <CardHeader>
<CardTitle>Type de publication</CardTitle> <CardTitle>Format</CardTitle>
<CardDescription>Choisissez où publier</CardDescription> <CardDescription>Comment la publication apparaîtra.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <RadioGroup
<Label htmlFor="postType">Format</Label> value={postType}
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}> onValueChange={(value) => setPostType(value as 'feed' | 'story' | 'both')}
<SelectTrigger id="postType" data-testid="select-post-type"> className="grid grid-cols-1 gap-2 sm:grid-cols-3"
<SelectValue /> data-testid="select-post-type"
</SelectTrigger> >
<SelectContent> {FORMATS.map(({ value, label, description, icon: Icon }) => (
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem> <label
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem> key={value}
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem> htmlFor={`format-${value}`}
</SelectContent> 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'}`}
</Select> data-testid={`option-${value}`}
<p className="text-xs text-muted-foreground"> >
{postType === 'feed' && 'Publication classique dans le fil d\'actualité'} <div className="flex items-center justify-between">
{postType === 'story' && 'Story éphémère (24h) - Nécessite un média'} <Icon className={`h-5 w-5 ${postType === value ? 'text-primary' : 'text-muted-foreground'}`} />
{postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'} <RadioGroupItem value={value} id={`format-${value}`} className="hidden sm:block" />
</p>
</div> </div>
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</label>
))}
</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> </CardContent>
</Card> </Card>
</div>
)}
<Card className="rounded-2xl border-border/50 shadow-lg"> {/* ÉTAPE 4 : Planification */}
<CardHeader className="p-6"> {step === 3 && (
<CardTitle>Planification</CardTitle> <Card className="fade-in">
<CardDescription>Programmez la publication (optionnel)</CardDescription> <CardHeader>
<CardTitle>Quand publier ?</CardTitle>
<CardDescription>Publiez tout de suite ou choisissez une date.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<div className="space-y-3"> <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> <Label>Date et heure</Label>
<DateTimePicker <DateTimePicker
value={scheduledDate} value={scheduledDate}
onChange={setScheduledDate} onChange={setScheduledDate}
occupiedDates={scheduledPosts selectedPageIds={selectedPages}
.filter(post => post.scheduledAt) placeholder="Choisir une date"
.map(post => new Date(post.scheduledAt!))}
placeholder="Publier immédiatement"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Les jours et heures déjà occupés sur vos magasins sont signalés dans le calendrier.</p>
Sélectionnez une date pour planifier, sinon publication immédiate
</p>
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
<StepNavigation
onBack={() => goTo(step - 1)}
backDisabled={step === 0}
onNext={() => goTo(step + 1)}
nextLabel={step === 0 && selectedMedia.length === 0 ? "Continuer sans média" : "Continuer"}
next={
step === STEPS.length - 1 ? (
<Button <Button
onClick={() => setPreviewModalOpen(true)} onClick={() => {
if (scheduleMode === 'later' && !scheduledDate) {
toast({ title: "Date manquante", description: "Choisissez une date ou publiez maintenant.", variant: "destructive" });
return;
}
setPreviewModalOpen(true);
}}
disabled={createPostMutation.isPending} disabled={createPostMutation.isPending}
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg" variant="brand"
size="lg" className="w-full sm:w-auto"
data-testid="button-preview-post" data-testid="button-preview-post"
> >
<Send className="w-4 h-4 mr-2" /> <Send className="w-4 h-4" />
Prévisualiser et publier Prévisualiser et {scheduleMode === 'later' ? 'programmer' : 'publier'}
</Button> </Button>
) : undefined
}
/>
</div> </div>
{/* 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> </div>
</main> ) : (
<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 +923,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>
@@ -778,6 +942,6 @@ export default function NewPost() {
onPublish={handleCreatePost} onPublish={handleCreatePost}
isPublishing={createPostMutation.isPending} isPublishing={createPostMutation.isPending}
/> />
</div> </>
); );
} }
File diff suppressed because it is too large. Load diff
+15 -13
View File
@@ -1,21 +1,23 @@
import { Card, CardContent } from "@/components/ui/card"; import { Link } from "wouter";
import { AlertCircle } from "lucide-react"; import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LogoMark } from "@/components/brand/logo";
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50"> <div className="flex min-h-screen w-full flex-col items-center justify-center bg-background p-6 text-center">
<Card className="w-full max-w-md mx-4"> <LogoMark size={44} />
<CardContent className="pt-6"> <div className="mt-8 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<div className="flex mb-4 gap-2"> <Compass className="h-6 w-6" />
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div> </div>
<p className="mt-4 text-sm font-medium text-primary">Erreur 404</p>
<p className="mt-4 text-sm text-gray-600"> <h1 className="mt-1 text-2xl font-semibold tracking-tight">Page introuvable</h1>
Did you forget to add the page to the router? <p className="mt-2 max-w-sm text-sm text-muted-foreground">
La page que vous cherchez n'existe pas ou a été déplacée.
</p> </p>
</CardContent> <Button asChild className="mt-6">
</Card> <Link href="/">Retour au tableau de bord</Link>
</Button>
</div> </div>
); );
} }
+245 -220
View File
@@ -1,78 +1,71 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; import { Plus, Trash2, RefreshCw, Bug, Code, ChevronDown, HelpCircle, KeyRound, Link2, MoreVertical } from "lucide-react";
import Sidebar from "@/components/sidebar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import TopBar from "@/components/topbar"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema"; import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns"; import {
import { fr } from "date-fns/locale"; useConnectFacebook,
RefreshTokenButton,
function getTokenExpirationStatus(expiresAt: string | null | undefined) { TokenAlertBanner,
if (!expiresAt) { TokenHealthPanel,
return { } from "@/components/facebook-token-status";
status: 'unknown' as const,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date();
const expiration = new Date(expiresAt);
const diffTime = expiration.getTime() - now.getTime();
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) {
return {
status: 'expired' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: 'Expiré'
};
} else if (daysLeft <= 7) {
return {
status: 'urgent' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
};
} else if (daysLeft <= 15) {
return {
status: 'warning' as const,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
daysLeft,
message: `${daysLeft} jours restants`
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
}
export default function PagesManagement() { export default function PagesManagement() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null); const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const [pageToDelete, setPageToDelete] = useState<ClientSocialPage | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
// redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
if (!network) return;
const result = params.get(network);
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
@@ -82,8 +75,8 @@ export default function PagesManagement() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] }); queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({ toast({
title: "Page supprimée", title: "Compte déconnecté",
description: "La page a été retirée avec succès", description: "Le compte a été retiré de Social Flow",
}); });
}, },
onError: () => { onError: () => {
@@ -95,180 +88,218 @@ export default function PagesManagement() {
}, },
}); });
const connectFacebook = useConnectFacebook();
const connectTiktok = useConnectTiktok();
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="default">
{sidebarOpen && ( <PageHeader
<div icon={Link2}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Comptes connectés"
onClick={() => setSidebarOpen(false)} description="Les pages Facebook, Instagram et comptes TikTok sur lesquels Social Flow publie."
/> actions={
)} <DropdownMenu>
<DropdownMenuTrigger asChild>
<div className={` <Button data-testid="button-connect-account">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out <Plus className="h-4 w-4" /> Connecter un compte <ChevronDown className="h-4 w-4 opacity-70" />
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} </Button>
`}> </DropdownMenuTrigger>
<Sidebar onLinkClick={() => setSidebarOpen(false)} /> <DropdownMenuContent align="end" className="w-72">
</div> <DropdownMenuItem onClick={connectFacebook} className="items-start gap-3 py-2.5" data-testid="button-connect-facebook">
<PlatformIcon platform="facebook" size="sm" />
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div className="flex justify-between items-center mb-8">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1> <p className="font-medium">Facebook & Instagram</p>
<p className="text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground">Recommandé · connexion permanente</p>
Connectez et gérez vos pages Facebook et Instagram
</p>
</div> </div>
</DropdownMenuItem>
<DropdownMenuItem onClick={connectTiktok} className="items-start gap-3 py-2.5" data-testid="button-connect-tiktok">
<PlatformIcon platform="tiktok" size="sm" />
<div>
<p className="font-medium">TikTok</p>
<p className="text-xs text-muted-foreground">Autorisation via TikTok</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialogOpen(true)} className="items-start gap-3 py-2.5" data-testid="button-add-page">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
</span>
<div>
<p className="font-medium">Ajout manuel</p>
<p className="text-xs text-muted-foreground">Avec un jeton d'accès (expire après 60 jours)</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} /> <EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
</div>
{/* Permissions required notice */} <TokenAlertBanner pages={pages} />
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Permissions Facebook requises pour la publication vidéo</p>
<p className="text-muted-foreground mb-2">
Le token de page doit inclure : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
</p>
<p className="text-muted-foreground text-xs">
Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via <code className="bg-muted px-1 rounded">/me/accounts</code> pour obtenir le Page Token.
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/debug/accesstoken/', '_blank')}
data-testid="button-debug-token"
>
<Bug className="w-4 h-4 mr-2" />
Débogueur de jeton
</Button>
<Button
variant="outline"
onClick={() => window.open('https://developers.facebook.com/tools/explorer', '_blank')}
data-testid="button-graph-explorer"
>
<Code className="w-4 h-4 mr-2" />
Graph Explorer
</Button>
</div>
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{[1, 2, 3].map((i) => ( {[1, 2].map((i) => (
<div key={i} className="h-48 bg-card rounded-2xl border border-border/50 animate-pulse" /> <div key={i} className="skeleton h-52 rounded-xl" />
))} ))}
</div> </div>
) : pages.length === 0 ? ( ) : pages.length === 0 ? (
<Card className="border-dashed rounded-2xl border-border/50 shadow-lg"> <EmptyState
<CardContent className="flex flex-col items-center justify-center py-16"> icon={Link2}
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4"> title="Aucun compte connecté"
<Plus className="w-8 h-8 text-muted-foreground" /> description="Connectez vos pages Facebook (et les comptes Instagram associés) ou un compte TikTok pour commencer à publier."
</div> action={
<h3 className="text-lg font-semibold mb-2">Aucune page connectée</h3> <div className="flex flex-col gap-2 sm:flex-row">
<p className="text-muted-foreground text-center mb-4"> <Button onClick={connectFacebook} data-testid="button-add-first-page">
Commencez par connecter vos pages Facebook et Instagram <PlatformIcon platform="facebook" size="sm" className="h-5 w-5 bg-transparent text-current" />
</p> Connecter Facebook
<Button onClick={() => setDialogOpen(true)} data-testid="button-add-first-page">
<Plus className="w-4 h-4 mr-2" />
Ajouter une page
</Button> </Button>
</CardContent> <Button variant="outline" onClick={connectTiktok}>
</Card> <PlatformIcon platform="tiktok" size="sm" className="h-5 w-5 bg-transparent" />
Connecter TikTok
</Button>
</div>
}
/>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{pages.map((page) => ( {pages.map((page) => (
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}> <Card key={page.id} className="flex flex-col" data-testid={`card-page-${page.id}`}>
<CardHeader> <CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex items-start justify-between"> <PlatformIcon platform={page.platform} size="lg" />
<div className="flex items-center gap-3"> <div className="min-w-0 flex-1">
{page.platform === 'facebook' ? ( <CardTitle className="truncate">{page.pageName}</CardTitle>
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center"> <CardDescription className="truncate">
<Facebook className="w-6 h-6 text-white" /> {platformLabel(page.platform)} · <span className="font-mono text-xs">{page.pageId}</span>
</div>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<Instagram className="w-6 h-6 text-white" />
</div>
)}
<div>
<CardTitle className="text-lg">{page.pageName}</CardTitle>
<CardDescription className="capitalize">
{page.platform}
</CardDescription> </CardDescription>
</div> </div>
</div> <DropdownMenu>
</div> <DropdownMenuTrigger asChild>
</CardHeader> <Button variant="ghost" size="icon" className="shrink-0 text-muted-foreground" aria-label="Plus d'actions" data-testid={`button-page-menu-${page.id}`}>
<CardContent className="space-y-3"> <MoreVertical className="h-4 w-4" />
<div className="text-sm text-muted-foreground">
ID: {page.pageId}
</div>
{/* Token expiration status */}
{(() => {
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4" />
<div className="flex-1">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600" />
)}
</div>
);
})()}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button> </Button>
<Button </DropdownMenuTrigger>
variant="ghost" <DropdownMenuContent align="end">
size="icon" {page.platform === "tiktok" ? (
onClick={() => deleteMutation.mutate(page.id)} <DropdownMenuItem onClick={() => { window.location.href = "/api/tiktok/connect"; }} data-testid={`button-reconnect-page-${page.id}`}>
disabled={deleteMutation.isPending} <RefreshCw className="h-4 w-4" /> Reconnecter
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => setEditingPage(page)} data-testid={`button-edit-page-${page.id}`}>
<KeyRound className="h-4 w-4" /> Remplacer le jeton
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.open(`https://developers.facebook.com/tools/debug/accesstoken/`, "_blank")} data-testid="button-debug-token">
<Bug className="h-4 w-4" /> Déboguer le jeton
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setPageToDelete(page)}
className="text-destructive focus:text-destructive"
data-testid={`button-delete-page-${page.id}`} data-testid={`button-delete-page-${page.id}`}
> >
<Trash2 className="w-4 h-4 text-destructive" /> <Trash2 className="h-4 w-4" /> Déconnecter
</Button> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
<TokenHealthPanel page={page} />
{page.platform !== "tiktok" && (
<div className="mt-auto flex justify-end">
<RefreshTokenButton page={page} />
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<Collapsible className="mt-8 rounded-xl border bg-card">
<CollapsibleTrigger className="group flex w-full items-center gap-3 p-4 text-left text-sm font-medium">
<HelpCircle className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Besoin d'aide pour connecter un compte ?</span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t px-4 pb-4 pt-3 text-sm text-muted-foreground">
<p>
<strong className="text-foreground">Préférez « Facebook & Instagram »</strong> plutôt que l'ajout manuel :
les jetons obtenus ainsi n'expirent pas et Social Flow les régénère seul en cas de révocation. Un jeton
collé à la main expire au bout de 60 jours et devra être remplacé.
</p>
<p className="text-xs">
Permissions nécessaires : <code className="rounded bg-muted px-1">pages_manage_posts</code>{" "}
<code className="rounded bg-muted px-1">pages_read_engagement</code>{" "}
<code className="rounded bg-muted px-1">publish_video</code>
</p>
<div className="flex flex-wrap gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => window.open("https://developers.facebook.com/tools/debug/accesstoken/", "_blank")}>
<Bug className="h-4 w-4" /> Débogueur de jeton
</Button>
<Button variant="outline" size="sm" onClick={() => window.open("https://developers.facebook.com/tools/explorer", "_blank")} data-testid="button-graph-explorer">
<Code className="h-4 w-4" /> Graph Explorer
</Button>
</div> </div>
</main> </CollapsibleContent>
</div> </Collapsible>
<AlertDialog open={!!pageToDelete} onOpenChange={(open) => !open && setPageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Déconnecter « {pageToDelete?.pageName} » ?</AlertDialogTitle>
<AlertDialogDescription>
Social Flow ne pourra plus publier sur ce compte. Vous pourrez le reconnecter à tout moment.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (pageToDelete) deleteMutation.mutate(pageToDelete.id);
setPageToDelete(null);
}}
>
Déconnecter
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Page>
); );
} }
/**
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
*/
function useConnectTiktok() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/tiktok/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "TikTok n'est pas configuré",
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
variant: "destructive",
});
return;
}
window.location.href = '/api/tiktok/connect';
};
return handleClick;
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook'); const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState(''); const [pageName, setPageName] = useState('');
@@ -312,17 +343,11 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button data-testid="button-add-page">
<Plus className="w-4 h-4 mr-2" />
Ajouter une page
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Ajouter une page</DialogTitle> <DialogTitle>Ajouter un compte manuellement</DialogTitle>
<DialogDescription> <DialogDescription>
Connectez une page Facebook ou Instagram pour commencer à publier Renseignez l'identifiant de la page et un jeton d'accès. Ce jeton expirera au bout de 60 jours.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -403,7 +428,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
> >
{addMutation.isPending ? ( {addMutation.isPending ? (
<> <>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> <RefreshCw className="w-4 h-4 animate-spin" />
Ajout... Ajout...
</> </>
) : ( ) : (
@@ -417,7 +442,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
); );
} }
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const { toast } = useToast(); const { toast } = useToast();
@@ -502,7 +527,7 @@ function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpe
> >
{editMutation.isPending ? ( {editMutation.isPending ? (
<> <>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> <RefreshCw className="w-4 h-4 animate-spin" />
Modification... Modification...
</> </>
) : ( ) : (
-459
View File
@@ -1,459 +0,0 @@
import { useState, useRef } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema";
interface AudioTrack {
id: string;
title: string;
fileName: string;
url: string;
duration: number;
}
export default function RemotionVideoPage() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [images, setImages] = useState<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [overlayText, setOverlayText] = useState("");
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// French Edge TTS voices
const edgeVoices = [
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
];
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [publishDescription, setPublishDescription] = useState("");
const [isPublishing, setIsPublishing] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const totalSelected = images.length + selectedLibraryImages.length;
const generateTextMutation = useMutation({
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
onSuccess: (data: any) => {
setGeneratedVariants(data.variants || []);
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
},
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
};
const toggleLibraryImage = (media: Media) => {
setSelectedLibraryImages(prev => {
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
if (images.length + prev.length >= 4) { toast({ title: "4 images maximum.", variant: "destructive" }); return prev; }
return [...prev, media];
});
};
const handleTtsPreview = async () => {
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
};
const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
};
const handleGenerate = async () => {
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
setIsRendering(true);
setVideoUrl(null);
try {
const formData = new FormData();
images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
const data = await response.json();
if (data.jobId) {
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
const checkStatus = async () => {
try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
throw new Error("Erreur de suivi du rendu");
}
const job = await res.json();
if (job.status === 'done') {
setVideoUrl(job.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
setIsRendering(false);
} else if (job.status === 'error') {
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
setIsRendering(false);
} else {
setTimeout(checkStatus, 3000);
}
} catch (e: any) {
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
setIsRendering(false);
}
};
setTimeout(checkStatus, 3000);
} else {
// Fallback for older sync backend
setVideoUrl(data.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
setIsRendering(false);
}
} catch (err: any) {
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
setIsRendering(false);
}
// Note: removed finally block so isRendering stays true during polling
};
const handlePublish = async () => {
if (!videoUrl) return;
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
setIsPublishing(true);
try {
const response = await apiRequest('POST', '/api/remotion/publish', {
videoUrl, pageIds: selectedPageIds,
scheduledFor: scheduledDate?.toISOString(),
description: publishDescription || undefined,
});
const data = await response.json();
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
toast({
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
});
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
} catch (err: any) {
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
} finally { setIsPublishing(false); }
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
<div className={`fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-2xl mx-auto space-y-6">
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* 1. Images */}
<Card>
<CardHeader>
<CardTitle>1. Images</CardTitle>
<CardDescription>Sélectionnez 3 à 4 images (bibliothèque ou upload).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center relative hover:bg-muted/50 transition-colors">
<input type="file" multiple accept="image/*" onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-10 w-10 text-muted-foreground mb-3" />
<p className="font-medium text-center">Glissez ou cliquez</p>
<p className="text-sm text-muted-foreground mt-1">{images.length} uploadée(s)</p>
</div>
{imageMediaList.length > 0 && (
<div className="pt-3 border-t border-border">
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
<div className="grid grid-cols-5 gap-2 max-h-44 overflow-y-auto">
{imageMediaList.map(media => {
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
return (
<div key={media.id} onClick={() => toggleLibraryImage(media)}
className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}>
<img src={media.originalUrl} className="w-full h-full object-cover" />
{isSelected && <div className="absolute top-1 right-1 bg-primary text-white p-0.5 rounded-full"><Check className="w-3 h-3" /></div>}
</div>
);
})}
</div>
</div>
)}
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
</CardContent>
</Card>
{/* 2. Texte + Voix */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
<CardDescription>La voix lira votre texte. Les hashtags s'affichent mais ne sont pas lus.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea placeholder="Décrivez votre produit pour générer un texte IA..." value={productInfo}
onChange={e => setProductInfo(e.target.value)} rows={2} />
<Button variant="secondary" className="w-full" disabled={!productInfo || generateTextMutation.isPending}
onClick={() => generateTextMutation.mutate(productInfo)}>
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-4 h-4 animate-spin" /> : <Sparkles className="mr-2 w-4 h-4" />}
Générer avec l'IA
</Button>
{generatedVariants.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Cliquez pour appliquer :</p>
{generatedVariants.map((v: any, i: number) => (
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}
className={`text-sm p-2 rounded border cursor-pointer hover:bg-accent transition-colors ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
{v.text}
</div>
))}
</div>
)}
<Textarea placeholder="Votre texte overlay..." value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={3} />
<div className="flex items-center gap-3">
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts">Activer la voix TTS</Label>
</div>
{ttsEnabled && (
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
{/* TTS Engine Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Moteur:</Label>
<div className="flex gap-1">
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
Edge
</Button>
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs"
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-B'); }}>
Gemini
</Button>
</div>
</div>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<Label className="text-xs font-medium shrink-0">Voix:</Label>
<Select value={ttsVoice} onValueChange={setTtsVoice}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ttsEngine === 'edge' ? (
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
) : (
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
)}
</SelectContent>
</Select>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
</div>
)}
</CardContent>
</Card>
{/* 3. Musique */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique de fond</CardTitle>
<CardDescription>Choisissez une musique de la bibliothèque ou uploadez la vôtre.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-h-48 overflow-y-auto space-y-1.5">
{tracksLoading ? (
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
) : audioTracks.length === 0 ? (
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
<p className="text-sm text-muted-foreground">Aucune musique disponible</p>
</div>
) : (
audioTracks.map(track => {
const isSelected = selectedTrack?.id === track.id;
const playing = isPlaying === track.id;
return (
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{track.title}</p>
<p className="text-xs text-muted-foreground">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
</div>
);
})
)}
</div>
<div className="relative">
<div className="border-2 border-dashed border-border rounded-lg p-3 flex items-center gap-3 hover:bg-muted/50 transition-colors cursor-pointer">
<input type="file" accept="audio/*"
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-5 w-5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground truncate">
{musicFile ? musicFile.name : "Ou uploadez votre propre fichier audio"}
</p>
{musicFile && <Check className="w-4 h-4 text-primary shrink-0" />}
</div>
</div>
{(musicFile || selectedTrack) && (
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm">
<Volume2 className="w-4 h-4" /> Volume : {Math.round(musicVolume * 100)}%
</Label>
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
</div>
)}
</CardContent>
</Card>
{/* 4. Page Facebook */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
<CardDescription>Sélectionnez la page cible (son nom apparaîtra en fin de vidéo).</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{facebookPages.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p>
) : (
facebookPages.map(page => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`pre-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
))
)}
</CardContent>
</Card>
{/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
{isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours (1-2 min)...</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
</Button>
{/* Vidéo générée */}
{videoUrl && (
<>
<div>
<p className="font-semibold text-green-600 mb-2">Vidéo prête :</p>
<video src={videoUrl} controls className="w-full rounded-lg shadow-lg" />
</div>
{/* Publication */}
<Card className="border-primary/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="w-4 h-4" /> Publier sur Facebook
</CardTitle>
<CardDescription>Sélectionnez la page et planifiez (optionnel).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedPageIds.length === 0 ? (
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
) : (
<p className="text-sm text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
onChange={e => setPublishDescription(e.target.value)} />
<div className="space-y-1">
<Label className="text-sm text-muted-foreground">Date de publication</Label>
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
occupiedDates={[]} placeholder="Publier immédiatement" />
</div>
<Button className="w-full" onClick={handlePublish}
disabled={isPublishing || selectedPageIds.length === 0}>
{isPublishing
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
</Button>
</CardContent>
</Card>
</>
)}
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
import { useState, useCallback } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { CalendarClock, Upload, Loader2, Check, Link2 } from "lucide-react";
import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { PlatformIcon } from "@/components/platform-icon";
import { EmptyState } from "@/components/empty-state";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker";
/**
* Programmation d'un Reel déjà monté : pas de rendu, la vidéo envoyée est
* publiée telle quelle par le planificateur à la date choisie.
*/
export default function ScheduleReel() {
const [, navigate] = useLocation();
const { toast } = useToast();
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
// Destinations acceptant un Reel
const facebookPages = pages.filter((p) => p.platform === "facebook");
const tiktokAccounts = pages.filter((p) => p.platform === "tiktok");
const videoList = allMedia.filter((m) => m.type === "video").slice(0, 12);
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json() as Promise<Media>;
},
onSuccess: (media) => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedVideo(media);
toast({ title: "Vidéo envoyée", description: media.fileName });
},
onError: () => {
toast({ title: "Erreur", description: "Impossible d'envoyer la vidéo", variant: "destructive" });
},
});
const onDrop = useCallback((files: File[]) => {
if (files[0]) uploadMutation.mutate(files[0]);
}, [uploadMutation]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "video/*": [".mp4", ".mov", ".webm"] },
maxFiles: 1,
disabled: uploadMutation.isPending,
});
const scheduleMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest("POST", "/api/posts", {
content: description,
postType: "reel",
mediaIds: [selectedVideo!.id],
pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(),
alsoStory,
});
return response.json();
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"], refetchType: "all" });
await queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
toast({ title: "Reel programmé", description: "Il sera publié à la date choisie." });
navigate("/calendar");
},
onError: (error: unknown) => {
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de programmer le Reel"),
variant: "destructive",
});
},
});
const isPast = !!scheduledDate && scheduledDate.getTime() <= Date.now();
const canSchedule = !!selectedVideo && selectedPages.length > 0 && !!scheduledDate && !isPast;
const togglePage = (id: string, checked: boolean) => {
setSelectedPages((prev) => (checked ? [...prev, id] : prev.filter((p) => p !== id)));
};
const renderTargetCheckbox = (page: SocialPage) => {
const checked = selectedPages.includes(page.id);
return (
<label
key={page.id}
htmlFor={`schedule-reel-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 truncate text-sm font-medium">{page.pageName}</span>
<Checkbox
id={`schedule-reel-page-${page.id}`}
checked={checked}
onCheckedChange={(value) => togglePage(page.id, value === true)}
/>
</label>
);
};
const sectionTitle = (n: number, title: string, done: boolean) => (
<CardTitle className="flex items-center gap-3">
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${done ? "bg-primary text-primary-foreground" : "bg-primary/10 text-primary"}`}
>
{done ? <Check className="h-4 w-4" /> : n}
</span>
{title}
</CardTitle>
);
return (
<Page width="narrow">
<PageHeader
icon={CalendarClock}
title="Programmer un Reel"
description="Publiez une vidéo déjà montée, telle quelle, à la date et l'heure choisies."
/>
<div className="space-y-6">
{/* 1. Vidéo */}
<Card>
<CardHeader>
{sectionTitle(1, "Vidéo", !!selectedVideo)}
<CardDescription>Format vertical 9:16 recommandé.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${
isDragActive ? "border-primary bg-primary/5" : "border-border bg-muted/30 hover:border-primary/50"
}`}
data-testid="dropzone-schedule-reel"
>
<input {...getInputProps()} />
{uploadMutation.isPending ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Envoi en cours...
</div>
) : (
<div className="space-y-1">
<Upload className="w-8 h-8 mx-auto text-muted-foreground" />
<p className="font-medium">Déposez votre Reel ici ou cliquez pour choisir</p>
<p className="text-xs text-muted-foreground">MP4, MOV ou WebM</p>
</div>
)}
</div>
{videoList.length > 0 && (
<div className="space-y-2">
<Label>Ou choisissez une vidéo de la médiathèque</Label>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{videoList.map((media) => {
const selected = selectedVideo?.id === media.id;
return (
<button
key={media.id}
type="button"
onClick={() => setSelectedVideo(media)}
className={`relative aspect-[9/16] rounded-lg overflow-hidden border-2 ${
selected ? "border-primary" : "border-transparent"
}`}
>
<MediaThumbnail
src={media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video"
/>
{selected && (
<div className="absolute inset-0 bg-primary/30 flex items-center justify-center">
<Check className="w-6 h-6 text-primary-foreground" />
</div>
)}
</button>
);
})}
</div>
</div>
)}
{selectedVideo && (
<p className="text-sm text-muted-foreground">
Sélectionnée : <span className="font-medium text-foreground">{selectedVideo.fileName}</span>
</p>
)}
</CardContent>
</Card>
{/* 2. Description */}
<Card>
<CardHeader>
{sectionTitle(2, "Description", !!description.trim())}
<CardDescription>Texte publié avec le Reel (facultatif).</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Texte publié avec le Reel (optionnel)"
rows={4}
data-testid="textarea-schedule-reel-description"
/>
</CardContent>
</Card>
{/* 3. Destinations et date */}
<Card>
<CardHeader>
{sectionTitle(3, "Destinations et date", canSchedule)}
</CardHeader>
<CardContent className="space-y-6">
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<EmptyState
compact
icon={Link2}
title="Aucun compte connecté"
description="Connectez une page Facebook ou un compte TikTok pour publier."
action={<Button size="sm" onClick={() => navigate("/pages")}>Connecter un compte</Button>}
/>
)}
{facebookPages.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Pages Facebook</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{facebookPages.map(renderTargetCheckbox)}</div>
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Comptes TikTok</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{tiktokAccounts.map(renderTargetCheckbox)}</div>
</div>
)}
<div className="space-y-2">
<Label>Date et heure de parution</Label>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
selectedPageIds={selectedPages}
placeholder="Choisir la date et l'heure"
/>
{isPast && (
<p className="text-sm text-destructive">La date doit être dans le futur.</p>
)}
</div>
<Button
className="w-full"
size="lg"
variant="brand"
disabled={!canSchedule || scheduleMutation.isPending}
onClick={() => scheduleMutation.mutate()}
data-testid="button-schedule-reel"
>
{scheduleMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CalendarClock className="w-4 h-4" />
)}
Programmer le Reel
</Button>
</CardContent>
</Card>
</div>
</Page>
);
}
+411 -175
View File
@@ -1,8 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react"; import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
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 Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
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";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -12,9 +13,30 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox"; import { ModelCombobox } from "@/components/model-combobox";
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
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 [sidebarOpen, setSidebarOpen] = useState(false); 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("");
@@ -31,6 +53,10 @@ export default function Settings() {
const [ffmpegApiKey, setFfmpegApiKey] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState(""); const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiApiKey, setGeminiApiKey] = useState("");
const [facebookAppId, setFacebookAppId] = useState("");
const [facebookAppSecret, setFacebookAppSecret] = useState("");
const [tiktokClientKey, setTiktokClientKey] = useState("");
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
const { toast } = useToast(); const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({ const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -50,12 +76,34 @@ export default function Settings() {
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({ const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const { data: ffmpegConfig } = useQuery({ const { data: ffmpegConfig } = useQuery({
queryKey: ['/api/ffmpeg/config'], queryKey: ['/api/ffmpeg/config'],
}); });
const { data: tiktokConfig } = useQuery<{
configured: boolean;
clientKey: string;
hasClientSecret: boolean;
redirectUri: string;
}>({
queryKey: ['/api/tiktok/config'],
});
const { data: facebookConfig } = useQuery<{
configured: boolean;
appId: string;
hasAppSecret: boolean;
redirectUri: string;
scopes: string[];
}>({
queryKey: ['/api/facebook/config'],
});
const { data: externalApiConfig } = useQuery({ const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'], queryKey: ['/api/settings/external-api'],
enabled: isAdmin, enabled: isAdmin,
@@ -92,6 +140,20 @@ export default function Settings() {
} }
}, [ffmpegConfig]); }, [ffmpegConfig]);
useEffect(() => {
if (tiktokConfig) {
// Le client secret n'est jamais renvoyé par l'API : il reste vide
setTiktokClientKey(tiktokConfig.clientKey || "");
}
}, [tiktokConfig]);
useEffect(() => {
if (facebookConfig) {
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
setFacebookAppId(facebookConfig.appId || "");
}
}, [facebookConfig]);
useEffect(() => { useEffect(() => {
if (geminiConfig) { if (geminiConfig) {
// Don't pre-fill - user must enter key manually // Don't pre-fill - user must enter key manually
@@ -216,7 +278,7 @@ export default function Settings() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] }); queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
toast({ toast({
title: "Logo uploadé", title: "Logo enregistré",
description: "Votre logo a été enregistré avec succès", description: "Votre logo a été enregistré avec succès",
}); });
setLogoFile(null); setLogoFile(null);
@@ -224,7 +286,7 @@ export default function Settings() {
onError: (error: Error) => { onError: (error: Error) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: error.message || "Impossible d'uploader le logo", description: error.message || "Impossible d'importer le logo",
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -264,6 +326,55 @@ export default function Settings() {
}, },
}); });
const saveFacebookMutation = useMutation({
mutationFn: () => {
const payload: any = { appId: facebookAppId.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (facebookAppSecret.trim() !== "") {
payload.appSecret = facebookAppSecret.trim();
}
return apiRequest('PUT', '/api/facebook/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
setFacebookAppSecret("");
toast({
title: "Configuration Facebook enregistrée",
description: "Les jetons de page se renouvellent désormais automatiquement",
});
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration Facebook",
variant: "destructive",
});
},
});
const saveTiktokMutation = useMutation({
mutationFn: () => {
const payload: any = { clientKey: tiktokClientKey.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (tiktokClientSecret.trim() !== "") {
payload.clientSecret = tiktokClientSecret.trim();
}
return apiRequest('PUT', '/api/tiktok/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tiktok/config'] });
setTiktokClientSecret("");
toast({ title: "Configuration TikTok enregistrée", description: "Vous pouvez maintenant connecter les comptes TikTok de vos magasins" });
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration TikTok",
variant: "destructive",
});
},
});
const deleteExternalApiMutation = useMutation({ const deleteExternalApiMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}), mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
onSuccess: () => { onSuccess: () => {
@@ -327,35 +438,32 @@ export default function Settings() {
}; };
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="default">
{sidebarOpen && ( <PageHeader
<div icon={SettingsIcon}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Paramètres"
onClick={() => setSidebarOpen(false)} description="Configurez les services utilisés par Social Flow."
/> />
)}
<div className={` <Tabs value={tab} onValueChange={changeTab} orientation="vertical" className="lg:grid lg:grid-cols-[220px_1fr] lg:gap-8">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out <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">
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} {SETTINGS_TABS.map(({ value, label, icon: Icon }) => (
`}> <TabsTrigger
<Sidebar onLinkClick={() => setSidebarOpen(false)} /> key={value}
</div> 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>
<main className="flex-1 overflow-y-auto"> <div className="min-w-0">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} /> <TabsContent value="general" className="mt-0 space-y-6">
<Card>
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto"> <CardHeader>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
<p className="text-muted-foreground mt-2">
Configurez votre application
</p>
</div>
<div className="space-y-8">
<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">
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
Notifications Notifications
@@ -396,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>
Importez 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 ? "Import 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. 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 ? "Import en cours…" : "Importer 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)
@@ -420,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
</> </>
) : ( ) : (
<> <>
@@ -477,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
@@ -499,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)
@@ -537,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"
@@ -558,157 +817,151 @@ 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"> <CardTitle className="flex items-center gap-2">
<Mic className="w-5 h-5" /> <Facebook className="w-5 h-5" />
Google Gemini TTS Facebook
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels Identifiants de votre application Facebook (developers.facebook.com). Ils
permettent de connecter vos pages en un clic et de renouveler leurs jetons
automatiquement, sans jamais recoller un token à la main.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{facebookConfig?.configured && (
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
<Key className="w-4 h-4" />
<span>Application Facebook configurée</span>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label> <Label htmlFor="facebookAppId">App ID</Label>
<Input <Input
id="geminiApiKey" id="facebookAppId"
type="password" value={facebookAppId}
value={geminiApiKey} onChange={(e) => setFacebookAppId(e.target.value)}
onChange={(e) => setGeminiApiKey(e.target.value)} placeholder="1234567890123456"
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
data-testid="input-gemini-api-key"
/> />
</div>
<div className="space-y-2">
<Label htmlFor="facebookAppSecret">
{facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
</Label>
<Input
id="facebookAppSecret"
type="password"
value={facebookAppSecret}
onChange={(e) => setFacebookAppSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URI de redirection à déclarer chez Facebook</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
</code>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{hasExistingGeminiConfig ? ( À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
<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> </p>
</div> </div>
{facebookConfig?.scopes?.length ? (
<div className="space-y-2">
<Label>Permissions demandées</Label>
<p className="text-xs text-muted-foreground break-all">
{facebookConfig.scopes.join(', ')}
</p>
</div>
) : null}
<Button <Button
onClick={() => saveGeminiMutation.mutate()} onClick={() => saveFacebookMutation.mutate()}
disabled={ disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
saveGeminiMutation.isPending ||
(!hasExistingGeminiConfig && !geminiApiKey)
}
data-testid="button-save-gemini"
className="w-full" className="w-full"
> >
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"} {saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{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">
<Image className="w-5 h-5" /> <SiTiktok className="w-5 h-5" />
Logo de l'entreprise TikTok
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Uploadez votre logo pour l'ajouter aux images éditées Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
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">
{logoPreview ? ( {tiktokConfig?.configured && (
<div className="space-y-4"> <div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
<div className="relative w-full max-w-xs mx-auto"> <Key className="w-4 h-4" />
<img <span>Application TikTok configurée</span>
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>
<div className="text-center"> )}
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
<div className="space-y-2">
<Label htmlFor="tiktokClientKey">Client key</Label>
<Input
id="tiktokClientKey"
value={tiktokClientKey}
onChange={(e) => setTiktokClientKey(e.target.value)}
placeholder="awxxxxxxxxxxxxxx"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tiktokClientSecret">
{tiktokConfig?.hasClientSecret ? "Nouveau client secret (laisser vide pour conserver l'actuel)" : "Client secret"}
</Label>
<Input
id="tiktokClientSecret"
type="password"
value={tiktokClientSecret}
onChange={(e) => setTiktokClientSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URL de redirection à déclarer chez TikTok</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{tiktokConfig?.redirectUri || `${window.location.origin}/api/tiktok/callback`}
</code>
<p className="text-xs text-muted-foreground">
Tant que l'application n'a pas passé l'audit TikTok, les vidéos publiées
restent privées (visibles du seul propriétaire du compte).
</p>
</div>
<Button <Button
variant="outline" onClick={() => saveTiktokMutation.mutate()}
disabled={saveTiktokMutation.isPending || !tiktokClientKey.trim()}
className="w-full" 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" /> {saveTiktokMutation.isPending ? "Enregistrement..." : "Enregistrer"}
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
</Button> </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> </CardContent>
</Card> </Card>
)} )}
</TabsContent>
<TabsContent value="api" 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"> <CardTitle className="flex items-center gap-2">
<Plug className="w-5 h-5" /> <Plug className="w-5 h-5" />
API externe API externe
@@ -720,7 +973,7 @@ export default function Settings() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{hasExistingExternalApiConfig && ( {hasExistingExternalApiConfig && (
<div className="rounded-lg bg-muted p-3 flex items-center justify-between"> <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"> <div className="flex items-center gap-2 text-sm text-success">
<Key className="w-4 h-4" /> <Key className="w-4 h-4" />
<span>Clé API configurée et active</span> <span>Clé API configurée et active</span>
</div> </div>
@@ -759,26 +1012,9 @@ export default function Settings() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</TabsContent>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Sécurité
</CardTitle>
<CardDescription>
Paramètres de sécurité et confidentialité
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Les paramètres de sécurité avancés seront disponibles prochainement.
</p>
</CardContent>
</Card>
</div>
</div>
</main>
</div> </div>
</Tabs>
</Page>
); );
} }
+49 -65
View File
@@ -1,17 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Database, Download, Play } from "lucide-react"; import { Database, Download, Play, Table2 } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export default function SqlAdmin() { export default function SqlAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [sqlQuery, setSqlQuery] = useState(""); const [sqlQuery, setSqlQuery] = useState("");
const [queryResult, setQueryResult] = useState<any>(null); const [queryResult, setQueryResult] = useState<any>(null);
@@ -75,77 +74,60 @@ export default function SqlAdmin() {
}; };
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <Page width="wide">
{sidebarOpen && ( <PageHeader
<div icon={Database}
className="fixed inset-0 bg-black/50 z-40 lg:hidden" title="Base de données"
onClick={() => setSidebarOpen(false)} description="Exécutez des requêtes SQL directement sur la base. À utiliser avec précaution."
/> />
)}
<div className={` <div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto space-y-6 sm:space-y-8">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Database className="w-8 h-8" />
Administration SQL
</h1>
<p className="text-muted-foreground mt-2">
Exécutez des requêtes SQL et gérez votre base de données
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
{/* Liste des tables */} {/* Liste des tables */}
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="h-fit lg:sticky lg:top-6">
<CardHeader className="p-6"> <CardHeader className="pb-3">
<CardTitle>Tables disponibles</CardTitle> <CardTitle>Tables</CardTitle>
<CardDescription> <CardDescription>Cliquez pour pré-remplir la requête</CardDescription>
Cliquez pour insérer dans la requête
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> {(tablesData as any)?.tables?.length ? (
{(tablesData as any)?.tables?.map((table: any) => ( <div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto lg:flex-col lg:flex-nowrap">
<Button {(tablesData as any).tables.map((table: any) => (
<button
key={table.tablename} key={table.tablename}
variant="outline" className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left font-mono text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground max-lg:border"
className="w-full justify-start text-left"
onClick={() => insertTable(table.tablename)} onClick={() => insertTable(table.tablename)}
data-testid={`button-table-${table.tablename}`} data-testid={`button-table-${table.tablename}`}
> >
<Database className="w-4 h-4 mr-2" /> <Table2 className="h-3.5 w-3.5 shrink-0" />
{table.tablename} <span className="truncate">{table.tablename}</span>
</Button> </button>
))} ))}
</div> </div>
) : (
<p className="text-sm text-muted-foreground">Aucune table trouvée.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Éditeur SQL et résultats */} {/* Éditeur SQL et résultats */}
<div className="lg:col-span-3 space-y-8"> <div className="space-y-6 lg:col-span-3">
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card>
<CardHeader className="p-6"> <CardHeader>
<CardTitle>Éditeur SQL</CardTitle> <CardTitle>Requête</CardTitle>
<CardDescription> <CardDescription>Ctrl + Entrée pour exécuter</CardDescription>
Entrez votre requête SQL ci-dessous
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Textarea <Textarea
placeholder="SELECT * FROM users LIMIT 10;" placeholder="SELECT * FROM users LIMIT 10;"
value={sqlQuery} value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)} onChange={(e) => setSqlQuery(e.target.value)}
className="font-mono min-h-[200px]" className="min-h-[200px] font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleExecuteQuery();
}
}}
data-testid="textarea-sql-query" data-testid="textarea-sql-query"
/> />
@@ -155,7 +137,7 @@ export default function SqlAdmin() {
disabled={executeSqlMutation.isPending} disabled={executeSqlMutation.isPending}
data-testid="button-execute-sql" data-testid="button-execute-sql"
> >
<Play className="w-4 h-4 mr-2" /> <Play className="h-4 w-4" />
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"} {executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
</Button> </Button>
@@ -172,13 +154,17 @@ export default function SqlAdmin() {
{/* Résultats */} {/* Résultats */}
{queryResult && ( {queryResult && (
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card>
<CardHeader className="p-6"> <CardHeader>
<div className="flex justify-between items-center"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<CardTitle>Résultats</CardTitle> <CardTitle>Résultats</CardTitle>
<CardDescription> <CardDescription>
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"} {queryResult.success
? Array.isArray(queryResult.result)
? `${queryResult.result.length} ligne(s)`
: "Requête exécutée avec succès"
: "Erreur lors de l'exécution"}
</CardDescription> </CardDescription>
</div> </div>
{queryResult.success && queryResult.result && ( {queryResult.success && queryResult.result && (
@@ -188,7 +174,7 @@ export default function SqlAdmin() {
onClick={handleDownloadResult} onClick={handleDownloadResult}
data-testid="button-download-result" data-testid="button-download-result"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="h-4 w-4" />
Télécharger JSON Télécharger JSON
</Button> </Button>
)} )}
@@ -196,7 +182,7 @@ export default function SqlAdmin() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{queryResult.success ? ( {queryResult.success ? (
<div className="overflow-x-auto"> <div className="max-h-[60vh] overflow-auto rounded-lg border">
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? ( {Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -225,7 +211,7 @@ export default function SqlAdmin() {
</TableBody> </TableBody>
</Table> </Table>
) : ( ) : (
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96"> <pre className="max-h-96 overflow-auto bg-muted p-4 text-xs">
<code>{JSON.stringify(queryResult.result, null, 2)}</code> <code>{JSON.stringify(queryResult.result, null, 2)}</code>
</pre> </pre>
)} )}
@@ -241,8 +227,6 @@ export default function SqlAdmin() {
)} )}
</div> </div>
</div> </div>
</div> </Page>
</main>
</div>
); );
} }
-40
View File
@@ -1,40 +0,0 @@
import React, { useState } from 'react';
import { CameraRecorder } from '@/components/camera-recorder';
import { Button } from '@/components/ui/button';
export default function TestCameraPage() {
const [capturedFile, setCapturedFile] = useState<File | null>(null);
const [showCamera, setShowCamera] = useState(false);
return (
<div className="p-8 bg-gray-900 min-h-screen text-white">
<h1 className="text-3xl font-bold mb-8">Test Caméra Isolé</h1>
<div className="space-y-6">
<Button onClick={() => setShowCamera(true)}>Ouvrir Caméra (react-webcam)</Button>
{capturedFile && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-2">Fichier Capturé :</h2>
<p>Nom: {capturedFile.name}</p>
<p>Taille: {(capturedFile.size / 1024 / 1024).toFixed(2)} MB</p>
<p>Type: {capturedFile.type}</p>
<video controls src={URL.createObjectURL(capturedFile)} className="mt-4 max-w-sm border border-white/20 rounded" />
</div>
)}
</div>
{showCamera && (
<CameraRecorder
onCapture={(file) => {
console.log("Captured:", file);
setCapturedFile(file);
setShowCamera(false);
}}
onCancel={() => setShowCamera(false)}
/>
)}
</div>
);
}
+88 -128
View File
@@ -1,20 +1,22 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar"; import { Card } from "@/components/ui/card";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { EmptyState } from "@/components/empty-state";
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, Settings } from "lucide-react"; import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, KeyRound } from "lucide-react";
type UserData = { type UserData = {
id: string; id: string;
@@ -36,13 +38,13 @@ type UserPagePermission = {
}; };
export default function UsersAdmin() { export default function UsersAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
// Form states // Form states
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user"); const [role, setRole] = useState<"admin" | "user">("user");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Edit dialog states // Edit dialog states
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -88,6 +90,7 @@ export default function UsersAdmin() {
description: `L'utilisateur ${data.username} a été créé avec succès`, description: `L'utilisateur ${data.username} a été créé avec succès`,
}); });
setUsername(""); setUsername("");
setCreateDialogOpen(false);
setPassword(""); setPassword("");
setRole("user"); setRole("user");
queryClient.invalidateQueries({ queryKey: ["/api/users"] }); queryClient.invalidateQueries({ queryKey: ["/api/users"] });
@@ -278,78 +281,43 @@ export default function UsersAdmin() {
}; };
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <>
{sidebarOpen && ( <Page width="default">
<div <PageHeader
className="fixed inset-0 bg-black/50 z-40 lg:hidden" icon={Users}
onClick={() => setSidebarOpen(false)} title="Utilisateurs"
description="Qui peut se connecter à Social Flow et sur quels comptes publier."
actions={
<Button onClick={() => setCreateDialogOpen(true)} data-testid="button-open-create-user">
<UserPlus className="h-4 w-4" /> Nouvel utilisateur
</Button>
}
/> />
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto space-y-6 sm:space-y-8">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="w-8 h-8" />
Gestion des utilisateurs
</h1>
<p className="text-muted-foreground mt-2">
Créez et gérez les utilisateurs de l'application
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
{/* Liste des utilisateurs */}
<div className="lg:col-span-2">
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Liste des utilisateurs</CardTitle>
<CardDescription>
{users?.length || 0} utilisateur(s) enregistré(s)
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8"> <div className="space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> {[1, 2, 3].map((i) => (
<p className="text-muted-foreground mt-4">Chargement...</p> <div key={i} className="skeleton h-16 rounded-xl" />
))}
</div> </div>
) : users && users.length > 0 ? ( ) : users && users.length > 0 ? (
<Table> <Card className="overflow-hidden">
<TableHeader> <ul className="divide-y">
<TableRow>
<TableHead>Nom d'utilisateur</TableHead>
<TableHead>Rôle</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => ( {users.map((user) => (
<TableRow key={user.id}> <li key={user.id} className="flex items-center gap-3 p-4" data-testid={`row-user-${user.id}`}>
<TableCell className="font-medium flex items-center gap-2"> <Avatar className="h-10 w-10">
{user.role === "admin" ? ( <AvatarFallback className={user.role === "admin" ? "bg-primary/10 font-semibold text-primary" : "bg-muted font-semibold text-muted-foreground"}>
<Shield className="w-4 h-4 text-primary" /> {user.username.substring(0, 2).toUpperCase()}
) : ( </AvatarFallback>
<UserIcon className="w-4 h-4 text-muted-foreground" /> </Avatar>
)} <div className="min-w-0 flex-1">
{user.username} <p className="truncate font-medium">{user.username}</p>
</TableCell> <Badge variant={user.role === "admin" ? "default" : "muted"} className="mt-1 gap-1">
<TableCell> {user.role === "admin" ? <Shield className="h-3 w-3" /> : <UserIcon className="h-3 w-3" />}
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role === "admin" ? "Administrateur" : "Utilisateur"} {user.role === "admin" ? "Administrateur" : "Utilisateur"}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="text-right"> <div className="flex shrink-0 items-center gap-1">
<div className="flex justify-end gap-2">
{user.role === "user" && ( {user.role === "user" && (
<Button <Button
variant="outline" variant="outline"
@@ -357,53 +325,51 @@ export default function UsersAdmin() {
onClick={() => handlePermissionsClick(user)} onClick={() => handlePermissionsClick(user)}
data-testid={`button-permissions-user-${user.id}`} data-testid={`button-permissions-user-${user.id}`}
> >
<Settings className="w-4 h-4" /> <KeyRound className="h-4 w-4" />
<span className="hidden sm:inline">Accès aux pages</span>
</Button> </Button>
)} )}
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
className="text-muted-foreground"
onClick={() => handleEditClick(user)} onClick={() => handleEditClick(user)}
aria-label="Modifier"
data-testid={`button-edit-user-${user.id}`} data-testid={`button-edit-user-${user.id}`}
> >
<Pencil className="w-4 h-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="destructive" variant="ghost"
size="sm" size="icon"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteClick(user)} onClick={() => handleDeleteClick(user)}
aria-label="Supprimer"
data-testid={`button-delete-user-${user.id}`} data-testid={`button-delete-user-${user.id}`}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </li>
</TableRow>
))} ))}
</TableBody> </ul>
</Table>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">Aucun utilisateur trouvé</p>
</div>
)}
</CardContent>
</Card> </Card>
</div> ) : (
<EmptyState
icon={Users}
title="Aucun utilisateur"
action={<Button onClick={() => setCreateDialogOpen(true)}>Créer un utilisateur</Button>}
/>
)}
</Page>
{/* Formulaire de création */} {/* Dialog de création */}
<div> <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<Card className="rounded-2xl border-border/50 shadow-lg"> <DialogContent className="sm:max-w-md">
<CardHeader className="p-6"> <DialogHeader>
<CardTitle className="flex items-center gap-2"> <DialogTitle>Nouvel utilisateur</DialogTitle>
<UserPlus className="w-5 h-5" /> <DialogDescription>Il pourra se connecter immédiatement avec ces identifiants.</DialogDescription>
Créer un utilisateur </DialogHeader>
</CardTitle>
<CardDescription>
Ajouter un nouvel utilisateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur</Label> <Label htmlFor="username">Nom d'utilisateur</Label>
@@ -417,7 +383,6 @@ export default function UsersAdmin() {
data-testid="input-new-username" data-testid="input-new-username"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label> <Label htmlFor="password">Mot de passe</Label>
<Input <Input
@@ -429,11 +394,8 @@ export default function UsersAdmin() {
required required
data-testid="input-new-password" data-testid="input-new-password"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">4 caractères minimum</p>
Minimum 4 caractères
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role">Rôle</Label> <Label htmlFor="role">Rôle</Label>
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}> <Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
@@ -441,27 +403,22 @@ export default function UsersAdmin() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="user">Utilisateur</SelectItem> <SelectItem value="user">Utilisateur — publie sur les pages autorisées</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem> <SelectItem value="admin">Administrateur — accès complet</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<DialogFooter>
<Button <Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)}>
type="submit" Annuler
className="w-full"
disabled={createUserMutation.isPending}
data-testid="button-create-user"
>
{createUserMutation.isPending ? "Création..." : "Créer"}
</Button> </Button>
<Button type="submit" disabled={createUserMutation.isPending} data-testid="button-create-user">
{createUserMutation.isPending ? "Création…" : "Créer l'utilisateur"}
</Button>
</DialogFooter>
</form> </form>
</CardContent> </DialogContent>
</Card> </Dialog>
</div>
</div>
</div>
</main>
{/* Dialog de modification */} {/* Dialog de modification */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}> <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
@@ -525,7 +482,7 @@ export default function UsersAdmin() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle> <AlertDialogTitle>Supprimer cet utilisateur ?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ? Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
Cette action est irréversible. Cette action est irréversible.
@@ -548,7 +505,7 @@ export default function UsersAdmin() {
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}> <Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle> <DialogTitle>Accès aux pages</DialogTitle>
<DialogDescription> <DialogDescription>
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
</DialogDescription> </DialogDescription>
@@ -557,7 +514,7 @@ export default function UsersAdmin() {
{allPages && allPages.length > 0 ? ( {allPages && allPages.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{allPages.map((page) => ( {allPages.map((page) => (
<div key={page.id} className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-accent"> <div key={page.id} className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent">
<Checkbox <Checkbox
id={`page-${page.id}`} id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)} checked={selectedPageIds.includes(page.id)}
@@ -568,9 +525,12 @@ export default function UsersAdmin() {
htmlFor={`page-${page.id}`} htmlFor={`page-${page.id}`}
className="flex-1 cursor-pointer" className="flex-1 cursor-pointer"
> >
<div className="flex items-center gap-3">
<PlatformIcon platform={page.platform} size="sm" />
<div>
<div className="font-medium">{page.pageName}</div> <div className="font-medium">{page.pageName}</div>
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground">{platformLabel(page.platform)}</div>
{page.platform === "facebook" ? "Facebook" : "Instagram"} </div>
</div> </div>
</Label> </Label>
</div> </div>
@@ -596,6 +556,6 @@ export default function UsersAdmin() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </>
); );
} }
-291
View File
@@ -1,291 +0,0 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
export type WordTiming = {
word: string;
startFrame: number;
endFrame: number;
};
export type ImageCompositionProps = {
images: string[];
overlayText?: string;
audioUrl?: string;
wordTimings?: WordTiming[];
musicUrl?: string;
musicVolume?: number;
logoUrl?: string;
storeName?: string;
endingFrames?: number;
};
/**
* Ken Burns presets — deterministic per image index.
* Each entry: starting transform → ending transform over the slide duration.
* Scale > 1 to avoid black edges when translating.
*/
const KB_PRESETS = [
// zoom in, center
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// zoom in + drift right
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
// zoom in + drift left
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
// zoom out, center
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// pan up + slight zoom
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
// pan down + zoom in
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
// diagonal drift bottom-right
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
// diagonal drift top-left + zoom out
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
];
/**
* ImageSlide with Ken Burns effect (slow zoom / pan).
* The effect index is derived from the image index for deterministic behaviour in Remotion.
*/
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
return (
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
<Img
src={src}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
transformOrigin: "center center",
willChange: "transform",
}}
/>
</AbsoluteFill>
);
};
/**
* CaptionGroup — renders a group of words (CapCut-style).
* Active word is highlighted in yellow with glow; others are white.
* Background: semi-transparent dark pill.
*/
const CaptionGroup: React.FC<{
words: WordTiming[];
activeIdx: number; // index within this group (0-3), -1 if none active
groupFirstFrame: number;
fps: number;
}> = ({ words, activeIdx, groupFirstFrame, fps }) => {
const frame = useCurrentFrame();
// Fade in when this group first appears
const fadeIn = spring({
frame: frame - groupFirstFrame,
fps,
config: { damping: 30, stiffness: 120 },
durationInFrames: 10,
});
return (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: "0 22px",
maxWidth: "90%",
opacity: fadeIn,
}}
>
{words.map((w, i) => {
const isActive = i === activeIdx;
return (
<span
key={i}
style={{
fontFamily: "'Arial Black', 'Impact', 'Helvetica Neue', sans-serif",
fontSize: isActive ? 82 : 74,
fontWeight: 900,
lineHeight: 1.15,
color: isActive ? "#FFE600" : "white",
textTransform: "uppercase",
textShadow: isActive
? "0 0 30px rgba(255,230,0,0.6), 0 4px 24px rgba(0,0,0,0.95)"
: "0 3px 18px rgba(0,0,0,0.9)",
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.85)" : "2px rgba(0,0,0,0.75)",
display: "inline-block",
transform: isActive ? "scale(1.1)" : "scale(1)",
transition: "transform 0.05s",
}}
>
{w.word}
</span>
);
})}
</div>
);
};
export const ImageComposition = ({
images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
logoUrl, storeName, endingFrames = 90,
}: ImageCompositionProps) => {
const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const hasEnding = !!(logoUrl || storeName);
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
const contentFrames = durationInFrames - effectiveEndingFrames;
const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1));
const endingStart = contentFrames;
// Ending slide spring animation
const endingProgress = spring({
frame: frame - endingStart,
fps,
config: { damping: 20, stiffness: 80 },
durationInFrames: 30,
});
// Group words into sets of 3 for caption display
const WORDS_PER_GROUP = 3;
const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1;
// Only show caption when a word is actively being spoken
const renderCaption = frame < endingStart && activeWordIdx >= 0;
const groupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : 0;
const groupStart = groupIdx * WORDS_PER_GROUP;
const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : [];
const activeIdxInGroup = activeWordIdx - groupStart;
// First frame of the current group (for fade-in)
const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0;
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images — displayed immediately, no entrance transition */}
{images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
<ImageSlide src={imgUrl} effectIndex={index} />
</Sequence>
))}
{/* TTS Audio */}
{audioUrl && <Html5Audio src={audioUrl} />}
{/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* Caption overlay — CapCut style */}
{renderCaption && (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 180,
}}
>
{/* Semi-transparent dark background pill */}
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",
borderRadius: 28,
paddingTop: 22,
paddingBottom: 22,
paddingLeft: 36,
paddingRight: 36,
maxWidth: "92%",
backdropFilter: "blur(4px)",
}}
>
<CaptionGroup
words={groupWords}
activeIdx={activeIdxInGroup}
groupFirstFrame={groupFirstFrame}
fps={fps}
/>
</div>
</AbsoluteFill>
)}
{/* Logo watermark — bottom right during content */}
{logoUrl && frame < endingStart && (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
<Img
src={logoUrl}
style={{
width: 150,
height: 150,
objectFit: "contain",
opacity: 0.85,
borderRadius: 20,
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
}}
/>
</AbsoluteFill>
)}
{/* Ending slide — large centered logo + store name */}
{hasEnding && (
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
<AbsoluteFill
style={{
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 48,
}}
>
{logoUrl && (
<Img
src={logoUrl}
style={{
width: 520,
height: 520,
objectFit: "contain",
opacity: endingProgress,
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
borderRadius: 32,
filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
}}
/>
)}
{storeName && (
<div
style={{
color: "white",
fontSize: 90,
fontFamily: "'Arial Black', Impact, sans-serif",
fontWeight: 900,
textAlign: "center",
opacity: endingProgress,
letterSpacing: 3,
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
}}
>
{storeName}
</div>
)}
</AbsoluteFill>
</Sequence>
)}
</AbsoluteFill>
);
};
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more