mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57372c04e9 | ||
|
|
719e57c04b | ||
|
|
def7d8a2cb | ||
|
|
7f21e23a91 | ||
|
|
b451826ec5 | ||
|
|
a0f800101c | ||
|
|
e221940ee4 | ||
|
|
9f72c626a2 | ||
|
|
66bcbe5edd | ||
|
|
f45e13d8b1 | ||
|
|
52833317e6 | ||
|
|
efbd6dde39 | ||
|
|
2bba946f34 | ||
|
|
d2293f846e | ||
|
|
9d14b2a5f0 | ||
|
|
73e4a4c822 | ||
|
|
25703abb7d | ||
|
|
f9828d4887 | ||
|
|
51f4406173 | ||
|
|
53ab6f489a | ||
|
|
62475eb43b | ||
|
|
79114a741b | ||
|
|
7a7188e508 | ||
|
|
bf23ecd226 | ||
|
|
cdbc4aee39 | ||
|
|
7c18dc1e16 | ||
|
|
155c3385c1 | ||
|
|
01315bf281 | ||
|
|
086ef956e2 | ||
|
|
7806f8bdc8 | ||
|
|
da9a74dbe6 | ||
|
|
e89dd97881 | ||
|
|
188706f4dc | ||
|
|
2c732c11b9 | ||
|
|
338546662f | ||
|
|
90a9b6be4d | ||
|
|
90f936cf4f | ||
|
|
5f271e0831 | ||
|
|
fdace9d8d5 | ||
|
|
e312733d43 | ||
|
|
44daa88a49 | ||
|
|
4b8fbe6d8e |
No files matched your search
@@ -3,7 +3,11 @@
|
||||
"allow": [
|
||||
"WebFetch(domain:pixabay.com)",
|
||||
"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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# Dépendances
|
||||
node_modules
|
||||
**/node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Fichiers de build
|
||||
@@ -38,3 +39,8 @@ logs
|
||||
|
||||
# Uploads (seront montés comme volume)
|
||||
uploads/*
|
||||
|
||||
# Compilation Rust
|
||||
server/target
|
||||
**/target
|
||||
.claude
|
||||
@@ -132,6 +132,21 @@ GEMINI_API_KEY=
|
||||
# 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 ;
|
||||
|
||||
@@ -6,8 +6,8 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
app:
|
||||
name: Application (TypeScript)
|
||||
frontend:
|
||||
name: Frontend (TypeScript)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -15,13 +15,29 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
# Les dépendances natives (canvas, sharp) et le navigateur Remotion ne
|
||||
# servent pas aux vérifications : on saute leurs scripts d'installation.
|
||||
- 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
|
||||
|
||||
+4
-1
@@ -32,4 +32,7 @@ npm-debug.log*
|
||||
*~
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
# Rust
|
||||
server/target
|
||||
.claude/worktrees
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
L'application utilise Docker Compose pour orchestrer :
|
||||
- **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
|
||||
|
||||
@@ -203,7 +204,8 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
|
||||
## 📊 Healthchecks
|
||||
|
||||
- **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
|
||||
|
||||
@@ -232,23 +234,37 @@ Vérifiez que `DATABASE_URL` dans `.env` correspond aux variables `PGUSER`, `PGP
|
||||
|
||||
## 📦 Volumes Docker
|
||||
|
||||
- `postgres_data` : Données persistantes de PostgreSQL
|
||||
- Mappages locaux :
|
||||
- `./attached_assets` → `/app/attached_assets` (médias uploadés)
|
||||
- `./migrations` → `/app/migrations` (migrations DB)
|
||||
- `postgres_data` : données persistantes de PostgreSQL
|
||||
- `encryption_key` : clé de chiffrement des jetons (`/app/.encryption-key-data`)
|
||||
- `audio_uploads`, `media_uploads`, `logos_uploads`, `stories_uploads` : fichiers téléversés (`/app/uploads/...`)
|
||||
|
||||
## 🔄 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
|
||||
# 1. Pull les dernières modifications
|
||||
# 1. Récupérer les dernières modifications
|
||||
git pull
|
||||
|
||||
# 2. Reconstruire l'image
|
||||
docker-compose build
|
||||
# 2. Reconstruire et redémarrer (Portainer : « Update the stack »)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Redémarrer
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Vérifier les logs
|
||||
docker-compose logs -f app
|
||||
# 3. 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
@@ -1,84 +1,68 @@
|
||||
# Debian slim — glibc requis par le Chrome headless shell de Remotion (posix_fallocate64, etc.)
|
||||
FROM node:20-slim
|
||||
# SocialFlow — image de l'application : serveur Rust + frontend React compilé.
|
||||
#
|
||||
# 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
|
||||
|
||||
# Augmenter la mémoire AVANT npm install + build (évite OOM sur les grosses dépendances)
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
COPY --from=backend /app/server/target/release/socialflow /usr/local/bin/socialflow
|
||||
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 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.mjs || 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
|
||||
RUN mkdir -p uploads/media uploads/logos uploads/stories uploads/audio uploads/temp
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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"]
|
||||
@@ -100,9 +100,9 @@ Architecture Production-Ready avec sécurité renforcée.
|
||||
|
||||
| Fonctionnalité | Détail |
|
||||
| ------------------------------- | ---------------------------------------------------- |
|
||||
| **Authentification** | Passport.js + bcrypt, sessions PostgreSQL |
|
||||
| **Rate Limiting** | 100 req/15min global, 5 tentatives login/15min |
|
||||
| **Headers de sécurité** | Helmet (CSP, HSTS, XSS protection) |
|
||||
| **Authentification** | bcrypt, sessions PostgreSQL (cookie signé) |
|
||||
| **Rate Limiting** | 1000 req/15min global, 30 tentatives login/15min |
|
||||
| **Headers de sécurité** | CSP, HSTS, X-Frame-Options, nosniff… |
|
||||
| **Chiffrement des tokens** | Tokens Facebook/Instagram chiffrés (AES-256) |
|
||||
| **Gestion multi-utilisateurs** | Rôles Admin / User, permissions par page |
|
||||
| **Console SQL** | Requêtes directes en base (admin uniquement) |
|
||||
@@ -117,43 +117,53 @@ Architecture Production-Ready avec sécurité renforcée.
|
||||
socialflow/
|
||||
├── client/ # Frontend React (Vite + TailwindCSS)
|
||||
│ └── src/
|
||||
│ ├── pages/ # 17 pages desktop + 13 pages mobile
|
||||
│ ├── components/ # 47 composants UI (Radix/shadcn)
|
||||
│ └── hooks/ # Hooks React personnalisés
|
||||
├── server/ # Backend Express.js (TypeScript)
|
||||
│ ├── routes.ts # 45+ endpoints REST API
|
||||
│ ├── routes/ # Routes modulaires (analytics, reels)
|
||||
│ ├── services/ # 12 services métier
|
||||
│ │ ├── analytics.ts # Sync Facebook Graph API
|
||||
│ │ ├── facebook.ts # Publication Facebook/Instagram
|
||||
│ │ ├── openrouter.ts # Génération texte IA
|
||||
│ │ ├── cloudinary.ts # Upload cloud médias
|
||||
│ │ ├── ffmpeg.ts # Encodage vidéo (microservice)
|
||||
│ │ ├── freesound.ts # Musique libre de droits
|
||||
│ │ ├── jamendo.ts # Musique libre de droits
|
||||
│ │ ├── 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)
|
||||
│ ├── pages/ # Pages desktop + mobile
|
||||
│ ├── components/ # Composants UI (Radix/shadcn)
|
||||
│ └── remotion/ # Compositions vidéo des Reels (aperçu + rendu)
|
||||
├── server/ # Backend Rust (axum + sqlx)
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/
|
||||
│ ├── main.rs # Démarrage : migrations, routes, tâches de fond
|
||||
│ ├── migrate.rs # Schéma PostgreSQL, mis à jour au démarrage
|
||||
│ ├── auth.rs # Sessions (cookie connect.sid) et rôles
|
||||
│ ├── db/ # Accès aux données
|
||||
│ ├── routes/ # API REST (/api, /api/v1 externe, pages légales)
|
||||
│ └── services/ # Facebook, TikTok, OpenRouter, Reels, scheduler, crons
|
||||
├── shared/ # Types et logique partagés par le frontend
|
||||
├── remotion-service/ # Rendu des Reels avec Remotion (Node + Chrome)
|
||||
├── ffmpeg-service/ # Microservice FFmpeg (Python/FastAPI)
|
||||
├── docker-compose.yml # Stack Docker (3 services)
|
||||
└── Dockerfile # Image production Node.js
|
||||
├── qwen-tts/ # Voix Qwen3-TTS (optionnel)
|
||||
├── docker-compose.yml # Stack Docker
|
||||
└── Dockerfile # Image de l'application (Rust + frontend compilé)
|
||||
```
|
||||
|
||||
### Stack Technique
|
||||
|
||||
| Couche | Technologies |
|
||||
| ---------- | --------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Framer Motion, Wouter |
|
||||
| **Backend** | Express.js, TypeScript, Passport.js, Helmet, node-cron |
|
||||
| **BDD** | PostgreSQL 16, Drizzle ORM, connect-pg-simple (sessions) |
|
||||
| **Frontend** | React 18, Vite, TailwindCSS, Radix UI, Recharts, Wouter |
|
||||
| **Backend** | Rust : axum, tokio, sqlx, reqwest |
|
||||
| **BDD** | PostgreSQL 16 (schéma créé et migré automatiquement au démarrage) |
|
||||
| **IA** | OpenRouter (multi-modèle : GPT-4, Claude, Gemini, etc.) |
|
||||
| **Médias** | Cloudinary (upload), Sharp (images), FFmpeg microservice (vidéo) |
|
||||
| **Musique** | FreeSound API, Jamendo API |
|
||||
| **Réseaux** | Facebook Graph API v19 (Pages, Instagram, Insights) |
|
||||
| **Médias** | Stockage local, FFmpeg (vignettes, microservice vidéo), Remotion (Reels) |
|
||||
| **Réseaux** | Facebook Graph API (Pages, Instagram, Insights), TikTok Content Posting |
|
||||
| **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
|
||||
@@ -161,7 +171,7 @@ socialflow/
|
||||
### Prérequis
|
||||
|
||||
- [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
|
||||
|
||||
@@ -199,11 +209,12 @@ FACEBOOK_APP_SECRET=...
|
||||
|
||||
```bash
|
||||
# Production (Docker)
|
||||
docker-compose up -d
|
||||
docker compose up -d --build
|
||||
|
||||
# Développement local
|
||||
# Développement local (deux terminaux)
|
||||
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 !
|
||||
|
||||
@@ -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)
|
||||
6. [Publications & Planification](#6-publications--planification)
|
||||
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)
|
||||
9. [IA & Modèles (OpenRouter)](#9-ia--modèles-openrouter)
|
||||
10. [Statistiques & Paramètres Système](#10-statistiques--paramètres-système)
|
||||
11. [Console SQL (Admin uniquement)](#11-console-sql-admin-uniquement)
|
||||
12. [API d'Analyse (Analytics)](#12-api-danalyse-analytics)
|
||||
13. [API Développeur Externe (v1)](#13-api-développeur-externe-v1)
|
||||
14. [Microservice Interne FFmpeg (Python)](#14-microservice-interne-ffmpeg-python)
|
||||
8. [IA & Modèles (OpenRouter)](#8-ia--modèles-openrouter)
|
||||
9. [Statistiques & Paramètres Système](#9-statistiques--paramètres-système)
|
||||
10. [Console SQL (Admin uniquement)](#10-console-sql-admin-uniquement)
|
||||
11. [API d'Analyse (Analytics)](#11-api-danalyse-analytics)
|
||||
12. [API Développeur Externe (v1)](#12-api-développeur-externe-v1)
|
||||
13. [Microservice Interne FFmpeg (Python)](#13-microservice-interne-ffmpeg-python)
|
||||
|
||||
---
|
||||
|
||||
@@ -727,72 +726,7 @@ GET /api/reels/status/:postId
|
||||
|
||||
---
|
||||
|
||||
## 8. Génération Vidéo Avancée (Remotion)
|
||||
|
||||
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)
|
||||
## 8. IA & Modèles (OpenRouter)
|
||||
|
||||
### Liste des modèles d'écriture IA disponibles
|
||||
```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
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
|
||||
+14
-2
@@ -4,11 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||
<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" />
|
||||
<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.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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<linearGradient id="g" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#4F46E5"/>
|
||||
<stop offset="0.6" stop-color="#7C3AED"/>
|
||||
<stop offset="1" stop-color="#F97362"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="50" cy="50" r="48" fill="url(#grad1)"/>
|
||||
|
||||
<!-- 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"/>
|
||||
<rect width="32" height="32" rx="9" fill="url(#g)"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 705 B |
+76
-119
@@ -1,98 +1,58 @@
|
||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||
import { useEffect } from "react";
|
||||
import { Switch, Route, useLocation, Link } from "wouter";
|
||||
import { lazy, Suspense, useEffect, type ComponentType } from "react";
|
||||
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 { 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 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
|
||||
const History = createResponsiveRoute(
|
||||
() => import("@/pages/history"),
|
||||
() => import("@/pages/mobile/history")
|
||||
);
|
||||
const Dashboard = createResponsiveRoute(
|
||||
() => import("@/pages/dashboard"),
|
||||
() => import("@/pages/mobile/dashboard")
|
||||
);
|
||||
function FullScreenLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LogoMark size={44} className="animate-pulse" />
|
||||
<p className="text-sm text-muted-foreground">Chargement…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NewPost = createResponsiveRoute(
|
||||
() => import("@/pages/new-post"),
|
||||
() => import("@/pages/mobile/new-post")
|
||||
);
|
||||
function PageLoader() {
|
||||
return (
|
||||
<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(
|
||||
() => 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 }) {
|
||||
function ProtectedRoute({ component: Component, adminOnly = false }: { component: ComponentType; adminOnly?: boolean }) {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: session, isLoading } = useQuery({
|
||||
queryKey: ["/api/auth/session"],
|
||||
retry: false,
|
||||
});
|
||||
const { session, isAdmin, isLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !session) {
|
||||
@@ -100,33 +60,31 @@ function ProtectedRoute({ component: Component, adminOnly = false }: { component
|
||||
}
|
||||
}, [isLoading, session, setLocation]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading) return <FullScreenLoader />;
|
||||
if (!session) return null;
|
||||
|
||||
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 />;
|
||||
return (
|
||||
<AppShell>
|
||||
{adminOnly && !isAdmin ? (
|
||||
<Page width="narrow">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
@@ -136,7 +94,7 @@ function Router() {
|
||||
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
|
||||
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</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="/media">{() => <ProtectedRoute component={Media} />}</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="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
|
||||
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
||||
<Route path="/test-camera" component={TestCamera} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
@@ -156,14 +113,14 @@ function Router() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<div>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, User, Lightbulb, History, Zap, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Bot, User, Sparkles, Loader2, Copy, RotateCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ModelCombobox } from "@/components/model-combobox";
|
||||
|
||||
@@ -90,33 +91,20 @@ export default function AiChat() {
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const copyVariant = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: "Texte copié", description: "Collez-le dans votre publication." });
|
||||
} catch {
|
||||
toast({ title: "Copie impossible", description: "Sélectionnez le texte manuellement.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg h-full flex flex-col">
|
||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Assistant IA</h3>
|
||||
<p className="text-sm text-muted-foreground">Génération de contenu intelligent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMessages([messages[0]])}
|
||||
className="rounded-xl"
|
||||
data-testid="button-new-chat"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Nouveau chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-foreground">Modèle IA:</label>
|
||||
<div className="flex h-[calc(100dvh-15rem)] min-h-[480px] flex-col overflow-hidden rounded-xl border bg-card shadow-soft lg:h-[calc(100vh-13rem)]">
|
||||
<div className="flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm text-muted-foreground">Modèle</span>
|
||||
<ModelCombobox
|
||||
models={availableModels}
|
||||
value={selectedModel}
|
||||
@@ -124,78 +112,91 @@ export default function AiChat() {
|
||||
placeholder="Sélectionner un modèle"
|
||||
isLoading={modelsLoading}
|
||||
disabled={modelsLoading}
|
||||
className="w-[300px] rounded-xl"
|
||||
className="w-full sm:w-[300px]"
|
||||
testId="select-ai-model"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMessages([messages[0]])}
|
||||
data-testid="button-new-chat"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Nouvelle conversation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-muted/20">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto bg-muted/30 p-4 sm:p-6">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-4 chat-message ${message.role === "user" ? "justify-end" : ""}`}
|
||||
>
|
||||
<div key={index} className={`flex gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
|
||||
{message.role === "assistant" && (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex-shrink-0 flex items-center justify-center shadow-md">
|
||||
<Bot className="text-white w-5 h-5" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 ${message.role === "user" ? "max-w-md ml-auto" : "max-w-2xl"}`}>
|
||||
<div className={`
|
||||
rounded-2xl p-6 shadow-md
|
||||
${message.role === "user"
|
||||
? "bg-gradient-to-br from-primary to-secondary text-white ml-auto"
|
||||
: "bg-card border border-border/50"
|
||||
|
||||
<div className={message.role === "user" ? "max-w-[85%] sm:max-w-md" : "max-w-full flex-1 sm:max-w-2xl"}>
|
||||
<div
|
||||
className={
|
||||
message.role === "user"
|
||||
? "rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-primary-foreground"
|
||||
: "rounded-2xl rounded-tl-sm border bg-card px-4 py-3"
|
||||
}
|
||||
`}>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
||||
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</p>
|
||||
|
||||
{message.variants && (
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="mt-4 space-y-3">
|
||||
{message.variants.map((variant, vIndex) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={vIndex}
|
||||
className="p-5 rounded-xl bg-muted/50 border border-border/50 cursor-pointer hover:bg-muted hover:shadow-md transition-all"
|
||||
onClick={() => copyVariant(variant.text)}
|
||||
className="group block w-full rounded-lg border bg-muted/40 p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
data-testid={`variant-${vIndex}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`
|
||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
||||
${vIndex === 0 ? "bg-gradient-to-r from-primary to-secondary text-white" : "bg-secondary/20 text-secondary"}
|
||||
`}>
|
||||
{variant.variant}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Badge variant={vIndex === 0 ? "default" : "muted"}>{variant.variant}</Badge>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{variant.characterCount} caractères
|
||||
<Copy className="h-3.5 w-3.5 opacity-60 group-hover:opacity-100" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-medium">{variant.characterCount} caractères</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">{variant.text}</p>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-foreground">{variant.text}</p>
|
||||
</button>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">Cliquez sur une proposition pour la copier.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-2 inline-block">
|
||||
{message.role === "user" ? "Maintenant" : "Il y a quelques secondes"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{message.role === "user" && (
|
||||
<div className="w-10 h-10 rounded-xl bg-secondary/20 flex-shrink-0 flex items-center justify-center">
|
||||
<User className="text-secondary w-5 h-5" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{generateMutation.isPending && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
L'IA rédige vos propositions…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-border/50 bg-card space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="border-t bg-card p-3 sm:p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
placeholder="Décrivez votre produit (nom, prix, caractéristiques)..."
|
||||
placeholder="Décrivez votre produit (nom, prix, caractéristiques)…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="flex-1 min-h-[80px] rounded-xl bg-muted/30 border-border/50 focus:border-primary/50"
|
||||
className="max-h-40 min-h-[48px] flex-1 resize-none"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -204,42 +205,20 @@ export default function AiChat() {
|
||||
}}
|
||||
data-testid="input-product-info"
|
||||
/>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={generateMutation.isPending || !input.trim()}
|
||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl px-6"
|
||||
variant="brand"
|
||||
className="h-12"
|
||||
data-testid="button-generate"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{generateMutation.isPending ? "Génération..." : "Générer"}
|
||||
{generateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">Générer</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={() => toast({ title: "Bientôt disponible", description: "Les suggestions de produits seront disponibles prochainement" })}
|
||||
data-testid="button-suggestions"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
Suggestions
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={() => toast({ title: "Bientôt disponible", description: "L'historique des générations sera disponible prochainement" })}
|
||||
data-testid="button-history"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
Historique
|
||||
</Button>
|
||||
<div className="ml-auto text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-success" />
|
||||
<span>Propulsé par OpenRouter</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 hidden text-xs text-muted-foreground sm:block">
|
||||
Entrée pour envoyer · Maj + Entrée pour aller à la ligne
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MetricsCard } from './MetricsCard';
|
||||
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 { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react';
|
||||
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);
|
||||
};
|
||||
|
||||
if (isLoadingPages) return <div>Chargement des pages...</div>;
|
||||
if (!pages.length) return <div>Aucune page connectée. Veuillez connecter une page Facebook dans les Paramètres.</div>;
|
||||
if (isLoadingPages) {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={selectedPageId || ''} onValueChange={setSelectedPageId}>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectTrigger className="w-full sm:w-[260px]">
|
||||
<SelectValue placeholder="Sélectionner une page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -119,9 +141,9 @@ export function AnalyticsDashboard() {
|
||||
</Select>
|
||||
|
||||
{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'}`}>
|
||||
Token : {selectedPage.tokenStatus || 'Inconnu'}
|
||||
</span>
|
||||
<Badge variant={selectedPage.tokenStatus === 'valid' ? 'success' : 'danger'} className="shrink-0">
|
||||
{selectedPage.tokenStatus === 'valid' ? 'Connexion active' : 'Connexion à vérifier'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -130,23 +152,23 @@ export function AnalyticsDashboard() {
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending || !selectedPageId}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Actualiser les données
|
||||
<RefreshCw className={`h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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
|
||||
title="Abonnés Totaux"
|
||||
title="Abonnés"
|
||||
value={latest?.followersCount?.toLocaleString() || 0}
|
||||
icon={<Users className="h-4 w-4 text-muted-foreground" />}
|
||||
trend={latest && previous ? getTrend(latest.followersCount, previous.followersCount) : undefined}
|
||||
description="depuis la dernière mise à jour"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Portée de la Page"
|
||||
title="Portée"
|
||||
value={latest?.pageReach?.toLocaleString() || 0}
|
||||
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
||||
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
|
||||
@@ -160,7 +182,7 @@ export function AnalyticsDashboard() {
|
||||
description="interactions totales (jour)"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Vues de Page"
|
||||
title="Vues de la page"
|
||||
value={(latest?.pageViews || 0).toLocaleString()}
|
||||
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||
trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined}
|
||||
@@ -170,22 +192,22 @@ export function AnalyticsDashboard() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historique de Croissance & Portée</CardTitle>
|
||||
<CardTitle>Évolution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis yAxisId="left" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--muted-foreground))" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip contentStyle={{ background: "hsl(var(--popover))", border: "1px solid hsl(var(--border))", borderRadius: 8, color: "hsl(var(--popover-foreground))" }} />
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Abonnés" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Portée" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="engagement" stroke="#ff7f50" name="Engagement" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="views" stroke="#ffa500" name="Vues" />
|
||||
<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" strokeWidth={2} dot={false} stroke="hsl(var(--chart-3))" name="Portée" />
|
||||
<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" strokeWidth={2} dot={false} stroke="hsl(var(--chart-5))" name="Vues" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,17 @@ export function MetricsCard({ title, value, description, trend, icon }: MetricsC
|
||||
return (
|
||||
<Card>
|
||||
<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}
|
||||
</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{value}</div>
|
||||
{(trend !== undefined || description) && (
|
||||
<p className="text-xs text-muted-foreground mt-1 flex items-center">
|
||||
{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" />}
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
<div
|
||||
key={post.id}
|
||||
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}`}
|
||||
>
|
||||
@@ -176,8 +176,8 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
<span className="font-semibold text-lg text-foreground">{time}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
isPending
|
||||
? 'bg-blue-500/20 text-blue-600'
|
||||
: 'bg-green-500/20 text-green-600'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-success/15 text-success'
|
||||
}`}>
|
||||
{isPending ? 'Programmé' : 'Publié'}
|
||||
</span>
|
||||
@@ -197,7 +197,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
data-testid={`button-preview-post-${post.id}`}
|
||||
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>
|
||||
{isPending && (
|
||||
<>
|
||||
@@ -209,7 +209,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
data-testid={`button-edit-post-${post.id}`}
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-5 h-5 text-blue-500" />
|
||||
<Edit className="w-5 h-5 text-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -219,7 +219,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
||||
data-testid={`button-delete-post-${post.id}`}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
<Trash2 className="w-5 h-5 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
@@ -126,6 +126,8 @@ export default function CalendarView() {
|
||||
<Smartphone className="w-2.5 h-2.5" />
|
||||
</div>
|
||||
);
|
||||
} else if (postType === 'reel') {
|
||||
return <Clapperboard className="w-3 h-3" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -191,52 +193,47 @@ export default function CalendarView() {
|
||||
};
|
||||
|
||||
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">
|
||||
<CalendarIcon className="text-white w-6 h-6" />
|
||||
</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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPreviousMonth}
|
||||
className="rounded-xl"
|
||||
data-testid="button-prev-month"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Précédent
|
||||
</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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextMonth}
|
||||
className="rounded-xl"
|
||||
data-testid="button-next-month"
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border bg-card shadow-soft">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3 sm:px-6">
|
||||
<h2 className="text-lg font-semibold capitalize text-foreground">
|
||||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
data-testid="button-today"
|
||||
>
|
||||
Aujourd'hui
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goToPreviousMonth}
|
||||
aria-label="Mois précédent"
|
||||
data-testid="button-prev-month"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goToNextMonth}
|
||||
aria-label="Mois suivant"
|
||||
data-testid="button-next-month"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className="p-8">
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="grid grid-cols-7 gap-2 mb-2">
|
||||
{daysOfWeek.map((day) => (
|
||||
<div key={day} className="bg-muted/30 py-3 rounded-lg text-center">
|
||||
<span className="text-sm font-semibold text-muted-foreground">{day}</span>
|
||||
<div key={day} className="py-1 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{day}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -246,8 +243,8 @@ export default function CalendarView() {
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
bg-card border border-border/50 rounded-xl p-4 min-h-[140px] transition-all hover:shadow-md
|
||||
${isToday(day.date) ? "ring-2 ring-primary shadow-lg" : ""}
|
||||
bg-card border rounded-lg p-2.5 min-h-[120px] transition-colors hover:border-primary/30
|
||||
${isToday(day.date) ? "border-primary/60 bg-primary/[0.03]" : ""}
|
||||
${!day.isCurrentMonth ? "opacity-40" : ""}
|
||||
`}
|
||||
data-testid={`calendar-day-${index}`}
|
||||
@@ -263,7 +260,7 @@ export default function CalendarView() {
|
||||
{day.date.getDate()}
|
||||
</span>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -289,8 +286,8 @@ export default function CalendarView() {
|
||||
key={idx}
|
||||
className={`
|
||||
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' : ''}
|
||||
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
|
||||
${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
|
||||
${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
|
||||
`}
|
||||
data-testid={`calendar-post-${post.id}`}
|
||||
>
|
||||
@@ -308,29 +305,29 @@ export default function CalendarView() {
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
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}`}
|
||||
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>
|
||||
{isPending && (
|
||||
<>
|
||||
<button
|
||||
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}`}
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-3 h-3 text-blue-500" />
|
||||
<Edit className="w-3 h-3 text-primary" />
|
||||
</button>
|
||||
<button
|
||||
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}`}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
<Trash2 className="w-3 h-3 text-destructive" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -368,8 +365,8 @@ export default function CalendarView() {
|
||||
key={idx}
|
||||
className={`
|
||||
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' : ''}
|
||||
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
|
||||
${isPending ? 'bg-primary/10 text-primary hover:bg-primary/20' : ''}
|
||||
${isPublished ? 'bg-success/15 text-success hover:bg-success/25' : ''}
|
||||
`}
|
||||
data-testid={`popover-post-${post.id}`}
|
||||
>
|
||||
@@ -387,29 +384,29 @@ export default function CalendarView() {
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
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}`}
|
||||
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>
|
||||
{isPending && (
|
||||
<>
|
||||
<button
|
||||
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}`}
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-3 h-3 text-blue-500" />
|
||||
<Edit className="w-3 h-3 text-primary" />
|
||||
</button>
|
||||
<button
|
||||
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}`}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
<Trash2 className="w-3 h-3 text-destructive" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -429,13 +426,13 @@ export default function CalendarView() {
|
||||
))}
|
||||
</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="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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Webcam from 'react-webcam';
|
||||
import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Video } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface CameraRecorderProps {
|
||||
onCapture: (file: File, options?: { stabilize?: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Quality configurations
|
||||
type VideoQuality = "4k" | "1080p" | "720p";
|
||||
|
||||
interface QualityConfig {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bitrate: number; // in bps
|
||||
}
|
||||
|
||||
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
|
||||
"4k": { label: "4K Vertical", width: 2160, height: 3840, bitrate: 40000000 },
|
||||
"1080p": { label: "Full HD (Reel)", width: 1080, height: 1920, bitrate: 12000000 }, // Align with 12Mbps max spec
|
||||
"720p": { label: "HD Vertical", width: 720, height: 1280, bitrate: 8000000 },
|
||||
};
|
||||
|
||||
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [timer, setTimer] = useState(0);
|
||||
|
||||
// Devices & Settings
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
|
||||
const [quality, setQuality] = useState<VideoQuality>('1080p');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [stabilize, setStabilize] = useState(true); // Stabilisation serveur activée par défaut
|
||||
|
||||
// Fetch devices
|
||||
const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => {
|
||||
const videoDevices = mediaDevices.filter(({ kind }) => kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
if (videoDevices.length > 0 && !selectedDeviceId) {
|
||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
||||
}
|
||||
}, [selectedDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.enumerateDevices().then(handleDevices).catch(err => {
|
||||
console.error("Error enumerating devices", err);
|
||||
setCameraError("Erreur liste périphériques: " + err.message);
|
||||
});
|
||||
}, [handleDevices]);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isRecording) {
|
||||
interval = setInterval(() => setTimer(t => t + 1), 1000);
|
||||
} else {
|
||||
setTimer(0);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isRecording]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Recording Logic
|
||||
const startRecording = useCallback(() => {
|
||||
if (!webcamRef.current || !webcamRef.current.stream) return;
|
||||
|
||||
chunksRef.current = [];
|
||||
const stream = webcamRef.current.stream;
|
||||
|
||||
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
|
||||
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||||
const currentConfig = QUALITY_CONFIGS[quality];
|
||||
|
||||
try {
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: currentConfig.bitrate // Bitrate dynamique selon qualité
|
||||
});
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
|
||||
setRecordedBlob(blob);
|
||||
setPreviewUrl(URL.createObjectURL(blob));
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
setIsRecording(true);
|
||||
} catch (e: any) {
|
||||
console.error("Recording error", e);
|
||||
toast({ title: "Erreur", description: "Impossible de lancer l'enregistrement: " + e.message, variant: "destructive" });
|
||||
}
|
||||
}, [webcamRef, toast]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (recordedBlob) {
|
||||
onCapture(
|
||||
new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }),
|
||||
{ stabilize }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMediaError = useCallback((error: string | DOMException) => {
|
||||
console.error("Webcam Error:", error);
|
||||
const msg = typeof error === 'string' ? error : error.message;
|
||||
setCameraError(msg);
|
||||
}, []);
|
||||
|
||||
const toggleCamera = () => {
|
||||
setFacingMode(prev => prev === "user" ? "environment" : "user");
|
||||
setSelectedDeviceId('');
|
||||
};
|
||||
|
||||
// Review Mode
|
||||
if (recordedBlob && previewUrl) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center p-4">
|
||||
<video src={previewUrl} controls className="w-full max-h-[80vh] rounded-lg mb-8" autoPlay playsInline />
|
||||
<div className="flex gap-4">
|
||||
<Button variant="secondary" size="lg" onClick={() => { setRecordedBlob(null); setPreviewUrl(null); }} className="rounded-full px-8 py-6 text-lg">
|
||||
<RotateCcw className="mr-2 h-5 w-5" /> Refaire
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleConfirm} className="rounded-full px-8 py-6 text-lg bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle className="mr-2 h-5 w-5" /> Valider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const config = QUALITY_CONFIGS[quality];
|
||||
const videoConstraints = {
|
||||
width: { ideal: config.width },
|
||||
height: { ideal: config.height },
|
||||
frameRate: { ideal: 30 }, // 30fps pour une capture fluide
|
||||
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||
facingMode: selectedDeviceId ? undefined : facingMode
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex justify-between items-center bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<Button variant="ghost" size="icon" onClick={onCancel} className="text-white bg-black/20 hover:bg-black/40">
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex gap-2">
|
||||
{isRecording ? (
|
||||
<div className="bg-red-600 px-3 py-1 rounded-full text-white font-mono text-sm flex items-center gap-2 animate-pulse">
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
{formatTime(timer)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={toggleCamera} className="bg-black/30 border-white/20 text-white backdrop-blur">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(!showSettings)} className="bg-black/30 border-white/20 text-white backdrop-blur">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Réglages</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Error Overlay */}
|
||||
{cameraError && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
|
||||
<h3 className="text-xl font-bold text-red-500 mb-2">Erreur Caméra</h3>
|
||||
<p className="text-white mb-6">{cameraError}</p>
|
||||
<p className="text-sm text-gray-400">Vérifiez vos permissions et que vous êtes bien en HTTPS (ou localhost).</p>
|
||||
<Button onClick={() => window.location.reload()} variant="outline" className="mt-4 text-black">Recharger</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Overlay */}
|
||||
{showSettings && !isRecording && (
|
||||
<div className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-[#1c1c1e] text-white p-6 rounded-2xl w-full max-w-sm border border-white/10 shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold">Paramètres</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowSettings(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70 block mb-2">Caméra</label>
|
||||
<Button variant="outline" className="w-full justify-start text-white border-white/20 hover:bg-white/10 mb-2" onClick={toggleCamera}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Basculer Avant/Arrière
|
||||
</Button>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{devices.map((device, idx) => (
|
||||
<button
|
||||
key={device.deviceId || idx}
|
||||
onClick={() => setSelectedDeviceId(device.deviceId)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm truncate border transition-colors ${selectedDeviceId === device.deviceId ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
|
||||
>
|
||||
{device.label || `Caméra ${idx + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70">Qualité</label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
{(Object.keys(QUALITY_CONFIGS) as VideoQuality[]).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setQuality(q)}
|
||||
className={`px-2 py-2 rounded text-sm border transition-colors ${quality === q ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
|
||||
>
|
||||
{q.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/70 flex items-center justify-between">
|
||||
<span>Stabilisation serveur</span>
|
||||
<button
|
||||
onClick={() => setStabilize(!stabilize)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${stabilize ? 'bg-green-500' : 'bg-gray-600'}`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${stabilize ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</label>
|
||||
<p className="text-xs text-white/50 mt-1">Corrige les tremblements après capture (traitement serveur)</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-6 bg-white text-black hover:bg-gray-200" onClick={() => setShowSettings(false)}>OK</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webcam Feed */}
|
||||
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden relative">
|
||||
<Webcam
|
||||
audio={true}
|
||||
ref={webcamRef}
|
||||
videoConstraints={videoConstraints}
|
||||
onUserMediaError={handleUserMediaError}
|
||||
className="absolute w-full h-full object-cover"
|
||||
muted // Local preview always muted to avoid feedback
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 pb-12 flex flex-col items-center justify-end bg-gradient-to-t from-black/80 to-transparent h-48 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="group relative flex items-center justify-center transition-all hover:scale-105 active:scale-95"
|
||||
>
|
||||
<div className={`w-20 h-20 rounded-full border-[6px] border-white flex items-center justify-center p-1 ${isRecording ? 'opacity-100' : ''}`}>
|
||||
<div className={`transition-all duration-300 ${isRecording ? 'w-8 h-8 rounded-md bg-red-500' : 'w-full h-full rounded-full bg-red-500'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CameraRecorder;
|
||||
@@ -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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,25 +8,32 @@ import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 {
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
occupiedDates?: Date[];
|
||||
/** Magasins visés : leurs publications déjà prévues sont signalées. */
|
||||
selectedPageIds?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
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 [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelectedDate(value);
|
||||
setMonth(value);
|
||||
setSelectedHour(value.getHours());
|
||||
setSelectedMinute(value.getMinutes());
|
||||
} else {
|
||||
@@ -63,15 +71,44 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
}
|
||||
};
|
||||
|
||||
// Convert occupied dates to a Set of date strings for efficient lookup
|
||||
const occupiedDateStrings = new Set(
|
||||
occupiedDates.map(date => format(date, 'yyyy-MM-dd'))
|
||||
// Publications qui occupent encore un créneau, jour par jour
|
||||
const activeItemsOn = useCallback(
|
||||
(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 isDateOccupied = (date: Date) => {
|
||||
return occupiedDateStrings.has(format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
const selectedDayItems = selectedDate ? itemsByDay.get(dayKey(selectedDate)) ?? [] : [];
|
||||
const hoursTaken = busyHours(selectedDayItems, selectedPageIds);
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
@@ -86,7 +123,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
)}
|
||||
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>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -95,16 +132,34 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
locale={fr}
|
||||
modifiers={{
|
||||
occupied: (date) => isDateOccupied(date)
|
||||
selectedStoreBusy: isSelectedStoreBusy,
|
||||
otherStoreBusy: isOtherStoreBusy,
|
||||
}}
|
||||
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))}
|
||||
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>
|
||||
</Popover>
|
||||
{value && (
|
||||
@@ -131,7 +186,7 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
className="w-full justify-start text-left font-normal"
|
||||
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')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -147,14 +202,16 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
<ScrollArea className="h-48 min-w-[96px] flex-1">
|
||||
<div className="p-1.5">
|
||||
<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
|
||||
key={hour}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-center mb-0.5 h-9 text-xs",
|
||||
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)}
|
||||
data-testid={`hour-${hour}`}
|
||||
@@ -163,8 +220,17 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
{isOptimalHour(hour) && selectedHour !== hour && (
|
||||
<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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea className="h-48 min-w-[80px] flex-1 border-l">
|
||||
@@ -189,25 +255,26 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
|
||||
</div>
|
||||
<div className="p-2 border-t bg-muted/50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
|
||||
<span className="text-[9px] font-semibold text-green-600 dark:text-green-400">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-success"></div>
|
||||
<span className="text-[9px] font-semibold text-success">
|
||||
Heures optimales (8h, 12h-13h, 18h-20h)
|
||||
</span>
|
||||
</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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{occupiedDates.length > 0 && (
|
||||
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20">
|
||||
<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>
|
||||
{selectedDate && (
|
||||
<DayAgendaPanel date={selectedDate} items={selectedDayItems} selectedPageIds={selectedPageIds} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,10 @@ 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: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
|
||||
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
|
||||
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||
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 {
|
||||
@@ -33,14 +33,14 @@ function statusOf(page: ClientSocialPage): TokenStatus {
|
||||
* 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 ConnectFacebookButton({ className }: { className?: string }) {
|
||||
export function useConnectFacebook() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: config } = useQuery<{ configured: boolean }>({
|
||||
queryKey: ['/api/facebook/config'],
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
return () => {
|
||||
if (config && !config.configured) {
|
||||
toast({
|
||||
title: "Facebook n'est pas configuré",
|
||||
@@ -52,6 +52,10 @@ export function ConnectFacebookButton({ className }: { className?: string }) {
|
||||
}
|
||||
window.location.href = '/api/facebook/connect';
|
||||
};
|
||||
}
|
||||
|
||||
export function ConnectFacebookButton({ className }: { className?: string }) {
|
||||
const handleClick = useConnectFacebook();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -60,7 +64,7 @@ export function ConnectFacebookButton({ className }: { className?: string }) {
|
||||
onClick={handleClick}
|
||||
data-testid="button-connect-facebook"
|
||||
>
|
||||
<Facebook className="w-4 h-4 mr-2" />
|
||||
<Facebook className="w-4 h-4" />
|
||||
Connecter des pages Facebook
|
||||
</Button>
|
||||
);
|
||||
@@ -108,8 +112,8 @@ export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
|
||||
disabled={refreshMutation.isPending}
|
||||
data-testid={`button-refresh-token-${page.id}`}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
|
||||
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{refreshMutation.isPending ? 'Vérification…' : 'Vérifier'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -130,9 +134,9 @@ export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
|
||||
<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 text-green-600 shrink-0" />
|
||||
<CheckCircle2 className={`w-4 h-4 shrink-0 ${style.color}`} />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
|
||||
<AlertTriangle className={`w-4 h-4 shrink-0 ${style.color}`} />
|
||||
)}
|
||||
<span className={`font-semibold ${style.color}`}>{style.label}</span>
|
||||
</div>
|
||||
@@ -183,10 +187,10 @@ export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
|
||||
if (failing.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<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-red-600 dark:text-red-400">
|
||||
<p className="font-semibold text-destructive">
|
||||
{failing.length === 1
|
||||
? 'Une page nécessite votre attention'
|
||||
: `${failing.length} pages nécessitent votre attention`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronRight, Home, Images, LogOut, Menu, Plus } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ThemeToggle } from "@/components/theme-provider";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { CREATE_ITEMS, isActivePath, visibleGroups } from "@/components/layout/nav-config";
|
||||
import { useLogout, useSession } from "@/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/", label: "Accueil", icon: Home, testId: "tab-home" },
|
||||
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "tab-calendar" },
|
||||
{ href: "/media", label: "Médias", icon: Images, testId: "tab-media" },
|
||||
];
|
||||
|
||||
/** Barre de navigation inférieure (mobile et tablette, < lg). */
|
||||
export function MobileNav() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { session, isAdmin } = useSession();
|
||||
const logout = useLogout();
|
||||
|
||||
const go = (href: string) => {
|
||||
setCreateOpen(false);
|
||||
setMenuOpen(false);
|
||||
setLocation(href);
|
||||
};
|
||||
|
||||
const tab = (t: (typeof TABS)[number]) => {
|
||||
const active = isActivePath(location, t.href);
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
|
||||
active ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-testid={t.testId}
|
||||
>
|
||||
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.4 : 2} />
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
// Les entrées déjà accessibles par les onglets ou « Créer » ne sont pas répétées dans le menu.
|
||||
const menuGroups = visibleGroups(isAdmin)
|
||||
.filter((g) => g.label !== "Créer")
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => !["/", "/calendar", "/media"].includes(i.href)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/95 backdrop-blur-lg pb-safe lg:hidden"
|
||||
aria-label="Navigation mobile"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-xl items-stretch px-2">
|
||||
{tab(TABS[0])}
|
||||
{tab(TABS[1])}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex h-12 w-12 -translate-y-3 items-center justify-center rounded-2xl gradient-brand text-white shadow-lg shadow-primary/30 ring-4 ring-background"
|
||||
aria-label="Créer"
|
||||
data-testid="tab-create"
|
||||
>
|
||||
<Plus className="h-6 w-6" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
{tab(TABS[2])}
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium",
|
||||
menuOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
data-testid="tab-menu"
|
||||
>
|
||||
<Menu className="h-[22px] w-[22px]" />
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent side="bottom" className="rounded-t-2xl pb-8">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle>Que voulez-vous créer ?</SheetTitle>
|
||||
<SheetDescription>Choisissez le type de contenu à préparer.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{CREATE_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => go(item.href)}
|
||||
className="flex flex-col items-start gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
data-testid={`create-${item.href.replace(/\W+/g, "-")}`}
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{item.label}</span>
|
||||
{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-2xl pb-8">
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription className="sr-only">Toutes les sections de Social Flow</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{session && (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl bg-muted/60 p-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||
{session.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menuGroups.map((group) => (
|
||||
<div key={group.label ?? "root"} className="mt-5">
|
||||
{group.label && (
|
||||
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group.label}</p>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
{group.items.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => go(item.href)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-accent",
|
||||
i > 0 && "border-t",
|
||||
isActivePath(location, item.href) && "text-primary",
|
||||
)}
|
||||
data-testid={`menu-${item.testId}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="flex-1 font-medium">{item.label}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Apparence</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium text-destructive hover:bg-destructive/5"
|
||||
data-testid="button-logout-mobile"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
||||
</button>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Database,
|
||||
Film,
|
||||
Home,
|
||||
Images,
|
||||
Link2,
|
||||
Music,
|
||||
PenSquare,
|
||||
Settings,
|
||||
UserCog,
|
||||
Wand2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Courte description affichée dans les menus mobiles. */
|
||||
description?: string;
|
||||
adminOnly?: boolean;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string | null;
|
||||
items: NavItem[];
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Source unique de la navigation (sidebar desktop, barre mobile, menus). */
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: null,
|
||||
items: [{ href: "/", label: "Tableau de bord", icon: Home, testId: "link-tableau-de-bord" }],
|
||||
},
|
||||
{
|
||||
label: "Créer",
|
||||
items: [
|
||||
{ href: "/new", label: "Publication", icon: PenSquare, description: "Photo ou vidéo avec texte", testId: "link-nouvelle-publication" },
|
||||
{ href: "/reel", label: "Reel", icon: Film, description: "Montage guidé avec musique et voix", testId: "link-nouveau-reel" },
|
||||
{ href: "/reel/schedule", label: "Programmer un Reel", icon: CalendarClock, description: "Vidéo déjà montée", testId: "link-programmer-un-reel" },
|
||||
{ href: "/image-editor", label: "Éditeur d'images", icon: Wand2, description: "Bandeaux, prix et logo", testId: "link-éditeur-d'images" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Planifier",
|
||||
items: [
|
||||
{ href: "/calendar", label: "Calendrier", icon: Calendar, testId: "link-calendrier" },
|
||||
{ href: "/history", label: "Historique", icon: Clock, testId: "link-historique" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Bibliothèque",
|
||||
items: [
|
||||
{ href: "/media", label: "Médiathèque", icon: Images, testId: "link-médiathèque" },
|
||||
{ href: "/audio-admin", label: "Musiques", icon: Music, adminOnly: true, testId: "link-audio-admin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Analyser",
|
||||
items: [
|
||||
{ href: "/analytics", label: "Statistiques", icon: BarChart3, testId: "link-analytics" },
|
||||
{ href: "/ai", label: "Assistant IA", icon: Bot, adminOnly: true, testId: "link-assistant-ia" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Administration",
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ href: "/pages", label: "Comptes connectés", icon: Link2, testId: "link-pages-gérées" },
|
||||
{ href: "/users", label: "Utilisateurs", icon: UserCog, testId: "link-users" },
|
||||
{ href: "/settings", label: "Paramètres", icon: Settings, testId: "link-settings" },
|
||||
{ href: "/sql", label: "Base de données", icon: Database, testId: "link-sql" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CREATE_ITEMS = NAV_GROUPS.find((g) => g.label === "Créer")!.items;
|
||||
|
||||
export function visibleGroups(isAdmin: boolean): NavGroup[] {
|
||||
return NAV_GROUPS.filter((g) => isAdmin || !g.adminOnly)
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => isAdmin || !i.adminOnly) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
export function isActivePath(location: string, href: string): boolean {
|
||||
if (href === "/") return location === "/";
|
||||
// /reel ne doit pas rester actif sur /reel/schedule
|
||||
if (href === "/reel") return location === "/reel";
|
||||
return location === href || location.startsWith(href + "/");
|
||||
}
|
||||
|
||||
/** Titre de la page courante (header mobile). */
|
||||
export function titleForPath(location: string): string {
|
||||
for (const g of NAV_GROUPS) {
|
||||
for (const i of g.items) {
|
||||
if (isActivePath(location, i.href)) return i.label;
|
||||
}
|
||||
}
|
||||
return "Social Flow";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
/** Boutons d'action affichés à droite (empilés sous le titre sur mobile). */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** En-tête standard de toutes les pages : icône, titre, description et actions. */
|
||||
export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between", className)}>
|
||||
<div className="flex min-w-0 items-start gap-3 sm:gap-4">
|
||||
{Icon && (
|
||||
<div className="hidden h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[28px] sm:leading-9">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground sm:text-[15px]">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Titre de section à l'intérieur d'une page. */
|
||||
export function SectionHeader({ title, description, actions, className }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,83 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Users } from "lucide-react";
|
||||
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowRight, Link2, Users } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PlatformIcon, platformLabel } from "@/components/platform-icon";
|
||||
|
||||
interface ManagedPage {
|
||||
id: string;
|
||||
pageName: string;
|
||||
platform: string;
|
||||
isActive: string;
|
||||
followersCount?: number | null;
|
||||
}
|
||||
|
||||
export default function ManagedPages() {
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
const { data: pages, isLoading } = useQuery<ManagedPage[]>({
|
||||
queryKey: ["/api/pages"],
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-8 shadow-lg">
|
||||
<div className="skeleton h-8 w-48 mb-6 rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-40 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-lg">
|
||||
<Users className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Pages gérées</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(pages as any[])?.length || 0} page(s) connectée(s)
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Comptes connectés</CardTitle>
|
||||
<CardDescription>{pages?.length ?? 0} compte(s) prêt(s) à publier</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages && (pages as any[]).length > 0 ? (
|
||||
(pages as any[]).map((page: any) => (
|
||||
<div
|
||||
key={page.id}
|
||||
className="border border-border/50 rounded-2xl p-6 hover:shadow-xl hover:border-primary/30 transition-all cursor-pointer bg-card"
|
||||
data-testid={`page-card-${page.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className={`
|
||||
w-14 h-14 rounded-xl flex items-center justify-center shadow-md
|
||||
${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
|
||||
`}>
|
||||
{page.platform === "facebook" ? (
|
||||
<SiFacebook className="text-[#1877F2] text-2xl" />
|
||||
) : page.platform === "tiktok" ? (
|
||||
<SiTiktok className="text-foreground text-2xl" />
|
||||
) : (
|
||||
<SiInstagram className="text-[#E4405F] text-2xl" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`
|
||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
||||
${page.isActive === "true" ? "bg-success/20 text-success" : "bg-muted text-muted-foreground"}
|
||||
`}>
|
||||
{page.isActive === "true" ? "Actif" : "Inactif"}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4 font-medium">
|
||||
{page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-medium">
|
||||
{page.followersCount?.toLocaleString() || 0} abonnés
|
||||
</span>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary">
|
||||
<Link href="/pages">
|
||||
Gérer <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-[72px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : pages && pages.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{pages.map((page) => (
|
||||
<div key={page.id} className="flex items-center gap-3 rounded-lg border p-3" data-testid={`page-card-${page.id}`}>
|
||||
<PlatformIcon platform={page.platform} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{page.pageName}</p>
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{platformLabel(page.platform)}
|
||||
{page.followersCount ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Users className="h-3 w-3" />
|
||||
{page.followersCount.toLocaleString("fr-FR")}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={page.isActive === "true" ? "success" : "muted"}>
|
||||
{page.isActive === "true" ? "Actif" : "Inactif"}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mx-auto mb-6">
|
||||
<Users className="w-10 h-10 text-muted-foreground opacity-50" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">Aucune page connectée</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={Link2}
|
||||
title="Aucun compte connecté"
|
||||
description="Connectez une page Facebook, Instagram ou TikTok pour commencer à publier."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/pages">Connecter un compte</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,18 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
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 { SiFacebook, SiInstagram } from "react-icons/si";
|
||||
|
||||
@@ -16,6 +27,7 @@ export default function MediaUpload() {
|
||||
const { toast } = useToast();
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
const [zoomImage, setZoomImage] = useState<string | null>(null);
|
||||
const [mediaToDelete, setMediaToDelete] = useState<any>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -213,219 +225,203 @@ export default function MediaUpload() {
|
||||
};
|
||||
}, [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 (
|
||||
<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 className="space-y-6">
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
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
|
||||
${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 className="flex-1">
|
||||
{uploadingCount > 0 ? (
|
||||
<>
|
||||
<p className="font-medium">Import en cours…</p>
|
||||
<p className="text-sm text-muted-foreground">{uploadingCount} fichier(s) en cours de traitement</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos images et vidéos"}</p>
|
||||
<p className="text-sm text-muted-foreground">PNG, JPG ou MP4 jusqu'à 50 Mo — recadrées automatiquement pour le fil et les stories.</p>
|
||||
</>
|
||||
)}
|
||||
</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="p-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<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
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
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">
|
||||
{uploadingCount > 0 ? (
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="w-8 h-8 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
{uploadingCount > 0 ? (
|
||||
<>
|
||||
<p className="text-foreground font-semibold text-lg mb-2">
|
||||
Téléchargement en cours...
|
||||
</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">
|
||||
{isDragActive ? "Déposez vos fichiers ici" : "Glissez-déposez vos fichiers"}
|
||||
</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 className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Vos médias</h2>
|
||||
<span className="text-xs text-muted-foreground">{totalMedia} élément(s)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
|
||||
<span>Fichiers téléchargés</span>
|
||||
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
|
||||
{totalMedia} média(s)
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{mediaList.map((media: any) => (
|
||||
<div
|
||||
key={media.id}
|
||||
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"
|
||||
data-testid={`media-item-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={media.type === 'video' ? 'video' : 'image'}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
{media.type === "image" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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"
|
||||
data-testid={`button-zoom-${media.id}`}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{mediaList.length === 0 ? (
|
||||
<EmptyState
|
||||
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
|
||||
key={media.id}
|
||||
onClick={() => setSelectedFile(media)}
|
||||
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}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={media.type === 'video' ? 'video' : 'image'}
|
||||
/>
|
||||
<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" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(media.id);
|
||||
setZoomImage(media.originalUrl);
|
||||
}}
|
||||
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"
|
||||
data-testid={`button-delete-${media.id}`}
|
||||
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}`}
|
||||
>
|
||||
<X className="w-4 h-4 text-white" />
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMediaToDelete(media);
|
||||
}}
|
||||
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}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Élément sentinelle pour le scroll infini */}
|
||||
{hasNextPage ? (
|
||||
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-semibold text-foreground">Aperçu et recadrage automatique</h4>
|
||||
|
||||
<div className="space-y-5">
|
||||
<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">Feed</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground bg-muted/30 px-2 py-1 rounded-md font-medium">1080×1080</span>
|
||||
</div>
|
||||
<div className="aspect-square bg-muted/30 rounded-lg overflow-hidden border border-border/50" style={{ width: '250px' }}>
|
||||
{selectedFile?.facebookFeedUrl || selectedFile?.instagramFeedUrl ? (
|
||||
<img
|
||||
src={selectedFile.facebookFeedUrl || selectedFile.instagramFeedUrl}
|
||||
alt="Feed preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="w-12 h-12 text-muted-foreground opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{/* Élément sentinelle pour le scroll infini */}
|
||||
{hasNextPage ? (
|
||||
<div ref={loadMoreRef} className="mt-3 flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<AlertDialog open={!!mediaToDelete} onOpenChange={(o) => !o && setMediaToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer ce média ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
« {mediaToDelete?.fileName} » sera définitivement retiré de la médiathèque.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (mediaToDelete) {
|
||||
deleteMutation.mutate(mediaToDelete.id);
|
||||
if (selectedFile?.id === mediaToDelete.id) setSelectedFile(null);
|
||||
}
|
||||
setMediaToDelete(null);
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<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 className="text-lg font-semibold text-foreground mb-1">
|
||||
Import 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.
|
||||
Votre fichier est en cours d'envoi et de recadrage.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
@@ -20,12 +23,7 @@ function getStageLabel(post: OngoingReel): string {
|
||||
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
|
||||
}
|
||||
|
||||
interface OngoingReelsProps {
|
||||
/** Compact layout for mobile */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
export default function OngoingReels() {
|
||||
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||
queryKey: ["/api/reels/ongoing"],
|
||||
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
||||
@@ -35,19 +33,36 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
(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)
|
||||
if (ongoingPosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : ""
|
||||
}`}
|
||||
data-testid="card-ongoing-reels"
|
||||
>
|
||||
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||
<Card data-testid="card-ongoing-reels">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
@@ -56,8 +71,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
Reels en cours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||
<div className={`space-y-${compact ? "3" : "4"}`}>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{ongoingPosts.map((post) => {
|
||||
const progress = post.generationProgress ?? 0;
|
||||
const isFailed = post.generationStatus === "failed";
|
||||
@@ -65,17 +80,17 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`flex items-start gap-3 p-${compact ? "3" : "4"} rounded-lg border border-border/50 bg-muted/30`}
|
||||
className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3"
|
||||
data-testid={`ongoing-reel-${post.id}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4 text-white" />
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${compact ? "text-sm" : "text-sm"}`}>
|
||||
<p className="font-medium truncate text-sm">
|
||||
{post.content || "Reel sans texte"}
|
||||
</p>
|
||||
|
||||
@@ -106,12 +121,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
)}
|
||||
|
||||
{progress >= 100 && !isFailed && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-green-500">
|
||||
<div className="flex items-center gap-1.5 mt-2 text-success">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Terminé</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Platform = "facebook" | "instagram" | "tiktok" | string;
|
||||
|
||||
const META: Record<string, { label: string; icon: typeof SiFacebook; text: string; bg: string }> = {
|
||||
facebook: { label: "Facebook", icon: SiFacebook, text: "text-facebook", bg: "bg-facebook/10" },
|
||||
instagram: { label: "Instagram", icon: SiInstagram, text: "text-instagram", bg: "bg-instagram/10" },
|
||||
tiktok: { label: "TikTok", icon: SiTiktok, text: "text-tiktok", bg: "bg-tiktok/10" },
|
||||
};
|
||||
|
||||
export function platformLabel(platform: Platform): string {
|
||||
return META[platform]?.label ?? platform;
|
||||
}
|
||||
|
||||
/** Icône de plateforme sur pastille teintée (couleurs officielles via tokens). */
|
||||
export function PlatformIcon({
|
||||
platform,
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
platform: Platform;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = META[platform] ?? META.facebook;
|
||||
const Icon = meta.icon;
|
||||
const box = { sm: "h-7 w-7 rounded-md", md: "h-10 w-10 rounded-lg", lg: "h-12 w-12 rounded-xl" }[size];
|
||||
const icon = { sm: "h-3.5 w-3.5", md: "h-5 w-5", lg: "h-6 w-6" }[size];
|
||||
return (
|
||||
<span className={cn("inline-flex shrink-0 items-center justify-center", box, meta.bg, meta.text, className)}>
|
||||
<Icon className={icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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">
|
||||
{/* Header */}
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
@@ -404,7 +405,7 @@ export function PreviewModal({
|
||||
<Instagram className="w-5 h-5 text-white" />
|
||||
</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" />
|
||||
</div>
|
||||
)}
|
||||
@@ -503,42 +504,31 @@ export function PreviewModal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prévisualisation de la publication</DialogTitle>
|
||||
<DialogTitle>Aperçu de la publication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Visualisez votre publication avant de la publier
|
||||
Vérifiez le rendu sur chaque réseau avant de valider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Format selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Format de prévisualisation:</label>
|
||||
<Select value={previewFormat} onValueChange={(value: PreviewFormat) => {
|
||||
setPreviewFormat(value);
|
||||
<Tabs
|
||||
value={previewFormat}
|
||||
onValueChange={(value) => {
|
||||
setPreviewFormat(value as PreviewFormat);
|
||||
setCurrentPhotoIndex(0);
|
||||
}}>
|
||||
<SelectTrigger className="w-64" data-testid="select-preview-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="facebook-feed" data-testid="option-facebook-feed">
|
||||
Feed Facebook (1200x630)
|
||||
</SelectItem>
|
||||
<SelectItem value="facebook-story" data-testid="option-facebook-story">
|
||||
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>
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4" data-testid="select-preview-format">
|
||||
<TabsTrigger value="facebook-feed" data-testid="option-facebook-feed">Facebook · Fil</TabsTrigger>
|
||||
<TabsTrigger value="facebook-story" data-testid="option-facebook-story">Facebook · Story</TabsTrigger>
|
||||
<TabsTrigger value="instagram-feed" data-testid="option-instagram-feed">Instagram · Fil</TabsTrigger>
|
||||
<TabsTrigger value="instagram-story" data-testid="option-instagram-story">Instagram · Story</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 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-story' && renderFacebookStory()}
|
||||
{previewFormat === 'instagram-feed' && renderInstagramFeed()}
|
||||
@@ -546,7 +536,7 @@ export function PreviewModal({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
{readOnly ? (
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -561,16 +551,16 @@ export function PreviewModal({
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-preview"
|
||||
>
|
||||
Annuler
|
||||
Modifier
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
disabled={isPublishing}
|
||||
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90"
|
||||
variant="brand"
|
||||
data-testid="button-publish-from-preview"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isPublishing ? 'Publication...' : 'Publier'}
|
||||
<Send className="w-4 h-4" />
|
||||
{isPublishing ? 'Envoi…' : 'Confirmer'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Image, Video, CalendarPlus, Clock, Plug, Zap, PlusCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { data: recentActivity } = useQuery({
|
||||
queryKey: ["/api/posts"],
|
||||
select: (data) => (data as any[])?.slice(0, 4) || [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||
<PlusCircle className="text-white w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Nouvelle publication</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl" data-testid="button-new-media-post">
|
||||
<Image className="w-4 h-4 mr-2" />
|
||||
Publication avec média
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-new-story">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Story vidéo
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full rounded-xl border-border/50" data-testid="button-schedule-series">
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Planifier série
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
|
||||
<Clock className="text-warning w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Activité récente</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivity && recentActivity.length > 0 ? (
|
||||
recentActivity.map((activity: any, index: number) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-4 pb-4 border-b border-border/50 last:border-0 last:pb-0"
|
||||
data-testid={`activity-${index}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-success mt-2 shadow-sm"></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{activity.status === "scheduled" ? "Publication planifiée" : "Brouillon créé"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-medium">
|
||||
{new Date(activity.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-12 h-12 text-muted-foreground opacity-30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Aucune activité récente</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border/50 p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center">
|
||||
<Zap className="text-success w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">État des services</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between" data-testid="status-openrouter">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">OpenRouter API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-facebook">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">Facebook API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-instagram">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">Instagram API</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" data-testid="status-database">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-sm"></div>
|
||||
<span className="text-sm font-medium text-foreground">PostgreSQL</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-success/20 text-success">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import { ArrowRight, Clock, CheckCircle2, XCircle, RotateCw, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PlatformIcon } from "@/components/platform-icon";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||
@@ -148,115 +151,110 @@ export default function RecentPublications() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg" data-testid="card-recent-publications">
|
||||
<CardHeader>
|
||||
<CardTitle>Historique des publications</CardTitle>
|
||||
<CardDescription>
|
||||
Vos 10 dernières publications sur les réseaux sociaux
|
||||
</CardDescription>
|
||||
<Card data-testid="card-recent-publications">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Dernières publications</CardTitle>
|
||||
<CardDescription>Les 10 publications les plus récentes</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary">
|
||||
<Link href="/history">
|
||||
Tout voir <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-muted rounded-lg animate-pulse" />
|
||||
<div key={i} className="skeleton h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : recentPublished.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Aucune publication pour le moment</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
compact
|
||||
icon={Clock}
|
||||
title="Aucune publication pour le moment"
|
||||
description="Vos publications apparaîtront ici dès qu'elles seront envoyées."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/new">Créer une publication</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ul className="divide-y">
|
||||
{recentPublished.map((scheduledPost) => (
|
||||
<div
|
||||
<li
|
||||
key={scheduledPost.id}
|
||||
className="flex items-start gap-4 p-4 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors"
|
||||
className="group flex items-center gap-3 py-3 first:pt-0 last:pb-0"
|
||||
data-testid={`publication-${scheduledPost.id}`}
|
||||
>
|
||||
{/* Platform icon */}
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
|
||||
? 'bg-blue-500'
|
||||
: scheduledPost.page?.platform === 'tiktok'
|
||||
? 'bg-black'
|
||||
: 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
}`}>
|
||||
{scheduledPost.page?.platform === 'facebook' ? (
|
||||
<Facebook className="w-5 h-5 text-white" />
|
||||
) : scheduledPost.page?.platform === 'tiktok' ? (
|
||||
<SiTiktok className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Instagram className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<PlatformIcon platform={scheduledPost.page?.platform ?? "facebook"} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm truncate" data-testid={`text-page-name-${scheduledPost.id}`}>
|
||||
{scheduledPost.page?.pageName || 'Page inconnue'}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{scheduledPost.post?.content ? (
|
||||
<span className="line-clamp-2">{scheduledPost.post.content}</span>
|
||||
) : (
|
||||
<span className="italic">Aucun texte</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePreviewPost(scheduledPost)}
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid={`button-preview-post-${scheduledPost.id}`}
|
||||
title="Prévisualiser"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(scheduledPost.postId)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
data-testid={`button-delete-post-${scheduledPost.id}`}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
{scheduledPost.error ? (
|
||||
<XCircle className="w-4 h-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === 'feed' ? 'Feed' :
|
||||
scheduledPost.postType === 'story' ? 'Story' :
|
||||
scheduledPost.postType === 'reel' ? 'Reel' : 'Feed & Story'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||
</span>
|
||||
{scheduledPost.error && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-destructive">Erreur</span>
|
||||
</>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium" data-testid={`text-page-name-${scheduledPost.id}`}>
|
||||
{scheduledPost.page?.pageName || "Page inconnue"}
|
||||
</p>
|
||||
{scheduledPost.error && !scheduledPost.publishedAt && scheduledPost.nextAttemptAt ? (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<RotateCw className="h-3 w-3" /> Nouvel essai
|
||||
</Badge>
|
||||
) : scheduledPost.error ? (
|
||||
<Badge variant="danger" className="gap-1">
|
||||
<XCircle className="h-3 w-3" /> Échec
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> Publié
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === "feed"
|
||||
? "Feed"
|
||||
: scheduledPost.postType === "story"
|
||||
? "Story"
|
||||
: scheduledPost.postType === "reel"
|
||||
? "Reel"
|
||||
: "Feed & Story"}
|
||||
<span>·</span>
|
||||
{format(new Date(scheduledPost.scheduledAt!), "d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePreviewPost(scheduledPost)}
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
data-testid={`button-preview-post-${scheduledPost.id}`}
|
||||
title="Prévisualiser"
|
||||
aria-label="Prévisualiser"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(scheduledPost.postId)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
data-testid={`button-delete-post-${scheduledPost.id}`}
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -274,7 +272,7 @@ export default function RecentPublications() {
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Supprimer cette publication ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action est irréversible. Cela supprimera définitivement cette publication de votre historique et des statistiques associées.
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from "@shared/captions";
|
||||
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
@@ -40,9 +38,7 @@ interface CommonProps {
|
||||
srtCues?: SrtCue[] | null;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
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 {
|
||||
@@ -75,12 +71,12 @@ function estimatedVoiceDuration(text: string): number {
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
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.kind === "video" && props.srtCues?.length ? props.srtCues : null;
|
||||
const srtCues = props.srtCues?.length ? props.srtCues : null;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = srtCues
|
||||
? ttsEnabled ? srtEnd(srtCues) : 0
|
||||
@@ -88,34 +84,6 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
@@ -153,8 +121,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
props.videoUrl,
|
||||
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
|
||||
]);
|
||||
@@ -172,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
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))}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function SrtUpload({ value, onChange }: SrtUploadProps) {
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
<Upload className="w-4 h-4" />
|
||||
Importer un fichier SRT
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
voicesFor,
|
||||
type TtsEngine,
|
||||
type TtsStyle,
|
||||
type VoiceOption,
|
||||
} from "@shared/voices";
|
||||
import type { TimedWord } from "@shared/captions";
|
||||
|
||||
@@ -76,7 +78,13 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
|
||||
useEffect(() => () => audioRef.current?.pause(), []);
|
||||
|
||||
const voices = voicesFor(value.engine);
|
||||
// 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") },
|
||||
@@ -133,7 +141,18 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
|
||||
<div className="flex gap-2">
|
||||
<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"
|
||||
@@ -204,11 +223,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : playing ? (
|
||||
<Square className="w-3 h-3 mr-2" />
|
||||
<Square className="w-3 h-3" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 mr-2" />
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
|
||||
</Button>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ComponentType, Suspense, lazy } from 'react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface ResponsiveRouteProps {
|
||||
desktop: ComponentType<any>;
|
||||
mobile: ComponentType<any>;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveRoute component that automatically switches between desktop and mobile versions
|
||||
* Uses lazy loading for optimal performance
|
||||
*/
|
||||
export function ResponsiveRoute({ desktop: Desktop, mobile: Mobile, fallback }: ResponsiveRouteProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Default loading fallback
|
||||
const defaultFallback = (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback || defaultFallback}>
|
||||
{isMobile ? <Mobile /> : <Desktop />}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create lazy-loaded responsive routes
|
||||
* Usage:
|
||||
* const Dashboard = createResponsiveRoute(
|
||||
* () => import('./pages/dashboard'),
|
||||
* () => import('./pages/mobile/dashboard')
|
||||
* );
|
||||
*/
|
||||
export function createResponsiveRoute(
|
||||
desktopImport: () => Promise<{ default: ComponentType<any> }>,
|
||||
mobileImport: () => Promise<{ default: ComponentType<any> }>
|
||||
) {
|
||||
const DesktopLazy = lazy(desktopImport);
|
||||
const MobileLazy = lazy(mobileImport);
|
||||
|
||||
return function ResponsiveRouteWrapper() {
|
||||
return <ResponsiveRoute desktop={DesktopLazy} mobile={MobileLazy} />;
|
||||
};
|
||||
}
|
||||
+151
-278
@@ -1,305 +1,178 @@
|
||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronsLeft, ChevronsRight, LogOut, Plus } from "lucide-react";
|
||||
import { Logo, LogoMark } from "@/components/brand/logo";
|
||||
import { ThemeToggle } from "@/components/theme-provider";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isActivePath, visibleGroups, type NavItem } from "@/components/layout/nav-config";
|
||||
import { useLogout, useSession } from "@/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
onLinkClick?: () => void;
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("sidebar-collapsed") === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
function NavLink({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||
const Icon = item.icon;
|
||||
const link = (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 rounded-lg px-3 py-[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 [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed');
|
||||
return saved === 'true';
|
||||
});
|
||||
const [adminExpanded, setAdminExpanded] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-admin-expanded');
|
||||
return saved !== 'false'; // default: expanded
|
||||
});
|
||||
const { session, isAdmin } = useSession();
|
||||
const logout = useLogout();
|
||||
const [collapsed, setCollapsed] = useState(readCollapsed);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-collapsed', String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-admin-expanded', String(adminExpanded));
|
||||
}, [adminExpanded]);
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
queryKey: ["/api/auth/session"],
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const isAdmin = session?.role === "admin";
|
||||
|
||||
const handleLinkClick = (href: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Always close sidebar on mobile first
|
||||
if (onLinkClick) {
|
||||
onLinkClick();
|
||||
try {
|
||||
localStorage.setItem("sidebar-collapsed", String(collapsed));
|
||||
} catch {
|
||||
/* stockage indisponible : on ignore */
|
||||
}
|
||||
// Navigate after a small delay to ensure state update completes
|
||||
setTimeout(() => {
|
||||
setLocation(href);
|
||||
}, 10);
|
||||
};
|
||||
}, [collapsed]);
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: [AUTH_MUTATION, "logout"],
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/auth/logout", {});
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
setLocation("/login");
|
||||
toast({
|
||||
title: "Déconnecté",
|
||||
description: "Vous avez été déconnecté avec succès",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: "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" },
|
||||
];
|
||||
const groups = visibleGroups(isAdmin);
|
||||
const initials = session?.username.substring(0, 2).toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<aside className={`bg-sidebar border-r border-sidebar-border flex flex-col h-screen transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-72'}`}>
|
||||
{/* Logo & Brand */}
|
||||
<div className="p-6 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 gradient-primary rounded-xl flex items-center justify-center shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold gradient-text">Social Flow</h1>
|
||||
<p className="text-xs text-muted-foreground">Automatisation sociale</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 lg:flex",
|
||||
collapsed ? "w-[76px]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border", collapsed ? "justify-center" : "px-5")}>
|
||||
<Link href="/" aria-label="Accueil Social Flow">
|
||||
{collapsed ? <LogoMark size={34} /> : <Logo size={34} />}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
<div className={cn("pt-4", collapsed ? "px-3" : "px-4")}>
|
||||
<Button asChild variant="brand" className={cn("w-full", collapsed && "px-0")} data-testid="button-sidebar-create">
|
||||
<Link href="/new" aria-label="Créer une publication">
|
||||
<Plus className="h-4 w-4" />
|
||||
{!collapsed && "Créer"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl font-medium transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold gradient-primary text-white shadow-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="mt-8 pt-6 border-t border-sidebar-border">
|
||||
{!isCollapsed && (
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-3 px-4 uppercase tracking-wider">Statistiques</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{statsItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
<nav className={cn("flex-1 overflow-y-auto py-4 scrollbar-none", collapsed ? "px-3" : "px-4")} aria-label="Navigation principale">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.label ?? gi} className={cn(gi > 0 && "mt-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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Admin & User Section */}
|
||||
<div className="p-4 border-t border-sidebar-border space-y-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
{/* Admin section toggle */}
|
||||
<button
|
||||
onClick={() => !isCollapsed && setAdminExpanded(v => !v)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-xl transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent relative group ${isCollapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<Shield className="w-4 h-4 shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider flex-1 text-left">Administration</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${adminExpanded ? '' : '-rotate-90'}`} />
|
||||
</>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
Administration
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Admin links — collapsible */}
|
||||
{(adminExpanded || isCollapsed) && (
|
||||
<div className={`space-y-0.5 ${!isCollapsed ? 'pl-2' : ''}`}>
|
||||
{[
|
||||
{ href: "/pages", icon: Users, label: "Pages gérées", testId: "link-pages-gérées" },
|
||||
{ href: "/ai", icon: Bot, label: "Assistant IA", testId: "link-assistant-ia" },
|
||||
{ href: "/users", icon: UserCog, label: "Utilisateurs", testId: "link-users" },
|
||||
{ href: "/sql", icon: Database,label: "SQL", testId: "link-sql" },
|
||||
{ href: "/audio-admin", icon: Music, label: "Bibliothèque Audio", testId: "link-audio-admin" },
|
||||
{ href: "/settings", icon: Settings,label: "Paramètres", testId: "link-settings" },
|
||||
].map(({ href, icon: Icon, label, testId }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={(e) => handleLinkClick(href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === href
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={testId}
|
||||
>
|
||||
{location === href && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{!isCollapsed && <span className="text-sm">{label}</span>}
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-2 relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-xl ${isCollapsed ? 'justify-center px-0' : 'justify-start'}`}
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending}
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className={`w-5 h-5 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && (logoutMutation.isPending ? "Déconnexion..." : "Déconnexion")}
|
||||
</Button>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 top-0">
|
||||
Déconnexion
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex flex-col items-center gap-2")}>
|
||||
{session && (
|
||||
<div className={`mt-4 bg-gradient-to-br from-primary/5 to-secondary/5 border border-primary/10 rounded-xl ${isCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<div className={`flex items-center gap-3 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<Avatar className="w-10 h-10 border-2 border-primary/20">
|
||||
<AvatarFallback className="gradient-primary text-white font-semibold">
|
||||
{session.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{session.role}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-sidebar-accent",
|
||||
collapsed && "justify-center",
|
||||
)}
|
||||
data-testid="button-user-menu"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={collapsed ? "right" : "top"} align="start" className="w-64">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-sm font-medium">{session.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Administrateur" : "Utilisateur"}</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">Apparence</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{logout.isPending ? "Déconnexion…" : "Se déconnecter"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Toggle Button (Desktop only) */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="mt-2 w-full hidden lg:flex items-center justify-center p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 border-2 border-border hover:border-primary/50 rounded-xl transition-all"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"mt-1 flex w-full items-center justify-center gap-2 rounded-lg p-2 text-xs text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
|
||||
)}
|
||||
aria-label={collapsed ? "Déplier le menu" : "Replier le menu"}
|
||||
data-testid="button-toggle-sidebar"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
||||
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <><ChevronsLeft className="h-4 w-4" /> Replier</>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarCheck, Users, Bot, Images, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { CalendarCheck, Link2, Sparkles, Images, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatsResponse {
|
||||
scheduledPosts: number;
|
||||
@@ -20,46 +22,49 @@ export default function StatsCards() {
|
||||
const statCards = [
|
||||
{
|
||||
icon: CalendarCheck,
|
||||
iconColor: "from-blue-500 to-blue-600",
|
||||
title: stats?.scheduledPosts ?? 0,
|
||||
subtitle: "Publications planifiées",
|
||||
tone: "bg-primary/10 text-primary",
|
||||
value: stats?.scheduledPosts ?? 0,
|
||||
label: "Publications planifiées",
|
||||
change: stats?.scheduledPostsChange,
|
||||
trending: stats?.scheduledPostsTrending,
|
||||
changeLabel: "vs mois dernier",
|
||||
href: "/calendar",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
iconColor: "from-purple-500 to-purple-600",
|
||||
title: stats?.connectedPages ?? 0,
|
||||
subtitle: "Pages connectées",
|
||||
info: "Facebook • Instagram",
|
||||
icon: Link2,
|
||||
tone: "bg-info/10 text-info",
|
||||
value: stats?.connectedPages ?? 0,
|
||||
label: "Comptes connectés",
|
||||
info: "Facebook · Instagram · TikTok",
|
||||
href: "/pages",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
iconColor: "from-green-500 to-green-600",
|
||||
title: stats?.aiTextsGenerated ?? 0,
|
||||
subtitle: "Textes générés par IA",
|
||||
icon: Sparkles,
|
||||
tone: "bg-brand-accent/15 text-brand-accent",
|
||||
value: stats?.aiTextsGenerated ?? 0,
|
||||
label: "Textes générés par l'IA",
|
||||
change: stats?.aiTextsChange,
|
||||
trending: stats?.aiTextsTrending,
|
||||
changeLabel: "vs hier",
|
||||
},
|
||||
{
|
||||
icon: Images,
|
||||
iconColor: "from-cyan-500 to-cyan-600",
|
||||
title: stats?.mediaStored ?? 0,
|
||||
subtitle: "Médias stockés",
|
||||
info: "Images • Vidéos",
|
||||
tone: "bg-success/10 text-success",
|
||||
value: stats?.mediaStored ?? 0,
|
||||
label: "Médias stockés",
|
||||
info: "Images · Vidéos",
|
||||
href: "/media",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-card rounded-2xl border border-border p-6 shadow-sm">
|
||||
<div className="skeleton h-12 w-12 rounded-xl mb-4" />
|
||||
<div className="skeleton h-10 w-20 mb-2" />
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div key={i} className="rounded-xl border bg-card p-4 shadow-soft sm:p-5">
|
||||
<div className="skeleton mb-4 h-10 w-10 rounded-lg" />
|
||||
<div className="skeleton mb-2 h-7 w-16 rounded" />
|
||||
<div className="skeleton h-4 w-28 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -67,45 +72,45 @@ export default function StatsCards() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
{statCards.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const TrendIcon = stat.trending === "up" ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group bg-card rounded-2xl border border-border p-6 hover:border-primary/30 transition-all cursor-pointer card-hover shadow-sm"
|
||||
data-testid={`stat-card-${index}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${stat.iconColor} flex items-center justify-center shadow-lg`}>
|
||||
<Icon className="text-white w-7 h-7" />
|
||||
</div>
|
||||
const body = (
|
||||
<>
|
||||
<div className={cn("mb-4 flex h-10 w-10 items-center justify-center rounded-lg", stat.tone)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-4xl font-bold text-foreground" data-testid={`stat-value-${index}`}>
|
||||
{stat.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{stat.change && (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}`}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
<span className="text-xs font-semibold">{stat.change}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{stat.changeLabel}</span>
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground sm:text-3xl" data-testid={`stat-value-${index}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{stat.label}</p>
|
||||
{stat.change ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium",
|
||||
stat.trending === "up" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
{stat.change}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{stat.changeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stat.info && (
|
||||
<div className="mt-4">
|
||||
<span className="text-xs text-muted-foreground font-medium">{stat.info}</span>
|
||||
</div>
|
||||
)}
|
||||
) : stat.info ? (
|
||||
<p className="mt-3 hidden text-xs text-muted-foreground sm:block">{stat.info}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const cls = "block rounded-xl border bg-card p-4 shadow-soft sm:p-5";
|
||||
return stat.href ? (
|
||||
<Link key={index} href={stat.href} className={cn(cls, "card-hover")} data-testid={`stat-card-${index}`}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={index} className={cls} data-testid={`stat-card-${index}`}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Menu, Search, ChevronLeft } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useLocation, Link } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuClick?: () => void;
|
||||
hideMenuButton?: boolean;
|
||||
}
|
||||
|
||||
export default function TopBar({ onMenuClick, hideMenuButton = false }: TopBarProps) {
|
||||
const [location] = useLocation();
|
||||
const showBackButton = location !== "/";
|
||||
|
||||
return (
|
||||
<header className="bg-card/50 backdrop-blur-xl border-b border-border/50 sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4 sm:py-5">
|
||||
<div className="flex items-center gap-3 sm:gap-6 flex-1">
|
||||
<div className="lg:hidden flex items-center gap-2">
|
||||
{showBackButton ? (
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="icon" className="text-foreground hover:text-primary transition-all">
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
!hideMenuButton && (
|
||||
<button
|
||||
className="text-foreground hover:text-primary transition-all hover:scale-110 active:scale-95 p-2 hover:bg-primary/10 rounded-xl"
|
||||
onClick={onMenuClick}
|
||||
data-testid="button-menu"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 max-w-md hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher des publications, pages, médias..."
|
||||
className="pl-10 bg-muted/30 border-muted focus:border-primary/50 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -15,6 +15,11 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-success/15 text-success",
|
||||
warning: "border-transparent bg-warning/15 text-warning-foreground dark:text-warning",
|
||||
info: "border-transparent bg-info/15 text-info",
|
||||
danger: "border-transparent bg-destructive/15 text-destructive",
|
||||
muted: "border-transparent bg-muted text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,25 +5,27 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
brand:
|
||||
"gradient-brand text-white shadow-md shadow-primary/20 hover:opacity-95 hover:shadow-lg hover:shadow-primary/25",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-11 rounded-md px-3",
|
||||
lg: "h-12 rounded-md px-8",
|
||||
icon: "h-11 w-11",
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-lg px-6 text-[15px]",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border bg-card text-card-foreground shadow-soft",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-5 sm:p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-base font-semibold leading-tight tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-5 pt-0 sm:p-6 sm:pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
className={cn("flex items-center p-5 pt-0 sm:p-6 sm:pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-card px-3 py-2 text-base shadow-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-ring/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-card shadow-sm px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 !" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+167
-150
@@ -2,106 +2,132 @@
|
||||
@tailwind components;
|
||||
@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 {
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
|
||||
--background: 210 40% 98%; /* #F8FAFC */
|
||||
--foreground: 222 47% 11%; /* #0F172A */
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
|
||||
--primary: 215 85% 58%;
|
||||
|
||||
--primary: 243 75% 59%; /* #4F46E5 indigo */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 265 75% 62%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
|
||||
--destructive: 0 85% 62%;
|
||||
|
||||
--secondary: 220 14% 94%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
|
||||
--brand-accent: 6 93% 68%; /* #F97362 corail */
|
||||
--brand-accent-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 220 14% 95%;
|
||||
--muted-foreground: 215 16% 42%;
|
||||
|
||||
--accent: 226 100% 97%;
|
||||
--accent-foreground: 243 60% 40%;
|
||||
|
||||
--destructive: 0 72% 51%; /* #DC2626 */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 215 85% 58%;
|
||||
|
||||
--success: 142 76% 48%;
|
||||
--warning: 35 95% 58%;
|
||||
--info: 195 85% 55%;
|
||||
|
||||
--chart-1: 215 85% 58%;
|
||||
--chart-2: 265 75% 62%;
|
||||
--chart-3: 142 76% 48%;
|
||||
--chart-4: 195 85% 55%;
|
||||
--chart-5: 35 95% 58%;
|
||||
|
||||
|
||||
--success: 142 72% 37%; /* #16A34A */
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 38 92% 50%; /* #F59E0B */
|
||||
--warning-foreground: 26 90% 14%;
|
||||
--info: 200 98% 39%; /* #0284C7 */
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
--facebook: 214 89% 52%; /* #1877F2 */
|
||||
--instagram: 345 75% 57%; /* #E4405F */
|
||||
--tiktok: 0 0% 7%;
|
||||
|
||||
--border: 220 13% 90%;
|
||||
--input: 220 13% 87%;
|
||||
--ring: 243 75% 59%;
|
||||
|
||||
--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-foreground: 222 47% 11%;
|
||||
--sidebar-primary: 215 85% 58%;
|
||||
--sidebar-primary: 243 75% 59%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 40% 96%;
|
||||
--sidebar-accent: 220 14% 96%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 91%;
|
||||
--sidebar-ring: 215 85% 58%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 243 75% 59%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
--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;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 20% 8%;
|
||||
--foreground: 0 0% 95%;
|
||||
|
||||
--card: 222 16% 15%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
|
||||
--popover: 222 18% 12%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
|
||||
--primary: 215 85% 58%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 265 75% 62%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 222 12% 20%;
|
||||
--muted-foreground: 222 10% 65%;
|
||||
|
||||
--accent: 222 18% 12%;
|
||||
--accent-foreground: 0 0% 95%;
|
||||
|
||||
--destructive: 0 85% 62%;
|
||||
--background: 224 47% 7%;
|
||||
--foreground: 210 40% 96%;
|
||||
|
||||
--card: 224 40% 11%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
|
||||
--popover: 224 40% 12%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
|
||||
--primary: 239 84% 70%;
|
||||
--primary-foreground: 224 47% 9%;
|
||||
|
||||
--secondary: 223 30% 18%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
|
||||
--brand-accent: 6 93% 70%;
|
||||
--brand-accent-foreground: 224 47% 9%;
|
||||
|
||||
--muted: 223 30% 16%;
|
||||
--muted-foreground: 217 20% 68%;
|
||||
|
||||
--accent: 236 40% 20%;
|
||||
--accent-foreground: 230 90% 88%;
|
||||
|
||||
--destructive: 0 84% 63%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 222 12% 20%;
|
||||
--input: 222 12% 20%;
|
||||
--ring: 215 85% 58%;
|
||||
|
||||
--success: 142 76% 48%;
|
||||
--warning: 35 95% 58%;
|
||||
--info: 195 85% 55%;
|
||||
|
||||
--chart-1: 215 85% 58%;
|
||||
--chart-2: 265 75% 62%;
|
||||
--chart-3: 142 76% 48%;
|
||||
--chart-4: 195 85% 55%;
|
||||
--chart-5: 35 95% 58%;
|
||||
|
||||
--sidebar: 222 20% 8%;
|
||||
--sidebar-foreground: 0 0% 95%;
|
||||
--sidebar-primary: 215 85% 58%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 222 18% 12%;
|
||||
--sidebar-accent-foreground: 0 0% 95%;
|
||||
--sidebar-border: 222 12% 20%;
|
||||
--sidebar-ring: 215 85% 58%;
|
||||
|
||||
--success: 142 64% 50%;
|
||||
--success-foreground: 144 60% 8%;
|
||||
--warning: 43 96% 56%;
|
||||
--warning-foreground: 26 90% 10%;
|
||||
--info: 199 89% 60%;
|
||||
--info-foreground: 204 80% 10%;
|
||||
|
||||
--facebook: 214 89% 62%;
|
||||
--instagram: 345 80% 65%;
|
||||
--tiktok: 0 0% 96%;
|
||||
|
||||
--border: 223 25% 20%;
|
||||
--input: 223 25% 24%;
|
||||
--ring: 239 84% 70%;
|
||||
|
||||
--chart-1: 239 84% 70%;
|
||||
--chart-2: 6 93% 70%;
|
||||
--chart-3: 142 64% 50%;
|
||||
--chart-4: 199 89% 60%;
|
||||
--chart-5: 43 96% 56%;
|
||||
|
||||
--sidebar: 224 45% 9%;
|
||||
--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 {
|
||||
@@ -109,50 +135,75 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--secondary)));
|
||||
border-radius: 5px;
|
||||
background: hsl(var(--muted-foreground) / 0.25);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid hsl(var(--background));
|
||||
}
|
||||
|
||||
::-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 */
|
||||
* {
|
||||
transition: background-color 200ms, border-color 200ms, color 200ms;
|
||||
}
|
||||
|
||||
button, a {
|
||||
transition: all 200ms;
|
||||
}
|
||||
|
||||
/* Animation for cards */
|
||||
/* Apparition douce des contenus */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -161,64 +212,23 @@ button, a {
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 150ms ease-out;
|
||||
animation: fadeIn 180ms ease-out;
|
||||
}
|
||||
|
||||
/* Gradient utilities */
|
||||
.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 */
|
||||
/* Survol des cartes cliquables */
|
||||
.card-hover {
|
||||
transition: transform 200ms, box-shadow 200ms;
|
||||
transition: transform 150ms, box-shadow 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px hsl(var(--primary) / 0.12);
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 8px 20px -8px hsl(var(--primary) / 0.25);
|
||||
}
|
||||
|
||||
/* Platform badges */
|
||||
.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 */
|
||||
/* Squelette de chargement */
|
||||
.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%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@@ -227,3 +237,10 @@ button, a {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in,
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-35
@@ -1,41 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import AiChat from "@/components/ai-chat";
|
||||
|
||||
export default function AI() {
|
||||
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 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 />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={Bot}
|
||||
title="Assistant IA"
|
||||
description="Discutez avec l'IA pour trouver des idées et rédiger vos textes."
|
||||
/>
|
||||
<AiChat />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+122
-137
@@ -1,8 +1,18 @@
|
||||
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 Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
|
||||
@@ -20,10 +30,10 @@ interface AudioTrack {
|
||||
}
|
||||
|
||||
export default function AudioAdmin() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [playingTrackId, setPlayingTrackId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
const [trackToDelete, setTrackToDelete] = useState<AudioTrack | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -58,7 +68,7 @@ export default function AudioAdmin() {
|
||||
const successCount = data.results?.filter((r: any) => r.success).length ?? 0;
|
||||
const failCount = data.results?.filter((r: any) => !r.success).length ?? 0;
|
||||
toast({
|
||||
title: "Upload terminé",
|
||||
title: "Import terminé",
|
||||
description: failCount > 0
|
||||
? `${successCount} fichier(s) ajouté(s), ${failCount} échec(s)`
|
||||
: `${successCount} fichier(s) ajouté(s) à la bibliothèque`,
|
||||
@@ -149,140 +159,115 @@ export default function AudioAdmin() {
|
||||
};
|
||||
|
||||
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)}
|
||||
<Page width="default">
|
||||
<input
|
||||
type="file"
|
||||
id="audio-upload"
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
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">
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{isUploading ? "Import en cours…" : "Importer des musiques"}
|
||||
</label>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-[76px] rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : tracks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Aucune musique"
|
||||
description="Importez des fichiers audio (MP3, WAV…) pour les proposer dans vos Reels."
|
||||
action={
|
||||
<Button asChild className="cursor-pointer">
|
||||
<label htmlFor="audio-upload">
|
||||
<Upload className="h-4 w-4" /> Importer des musiques
|
||||
</label>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
key={track.id}
|
||||
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}`}
|
||||
>
|
||||
<Button
|
||||
variant={playing ? "default" : "secondary"}
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0 rounded-full"
|
||||
onClick={() => togglePlay(track)}
|
||||
aria-label={playing ? "Pause" : "Écouter"}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setTrackToDelete(track)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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
|
||||
type="file"
|
||||
id="audio-upload"
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label htmlFor="audio-upload">
|
||||
<Button
|
||||
asChild
|
||||
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>
|
||||
</div>
|
||||
</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 ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : tracks.length === 0 ? (
|
||||
<div className="text-center p-12 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-1">Aucune musique</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Vous n'avez pas encore ajouté de piste audio. Les musiques ajoutées ici seront utilisables pour les Reels.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="p-3 bg-primary/10 rounded-lg shrink-0">
|
||||
<Music className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate" title={track.title}>{track.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(track.createdAt), "dd MMM yyyy", { locale: fr })}
|
||||
</p>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm("Voulez-vous vraiment supprimer cette musique ?")) {
|
||||
deleteMutation.mutate(track.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AlertDialog open={!!trackToDelete} onOpenChange={(open) => !open && setTrackToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Link } from "wouter";
|
||||
import { Calendar as CalendarIcon, Plus } from "lucide-react";
|
||||
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";
|
||||
|
||||
export default function Calendar() {
|
||||
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 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 />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title="Calendrier"
|
||||
description="Visualisez et gérez vos publications programmées."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/new">
|
||||
<Plus className="h-4 w-4" /> Programmer
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CalendarView />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,56 @@
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Link } from "wouter";
|
||||
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 OngoingReels from "@/components/ongoing-reels";
|
||||
import RecentPublications from "@/components/recent-publications";
|
||||
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() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { session } = useSession();
|
||||
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
title={`${greeting()}${session ? ` ${session.username}` : ""} 👋`}
|
||||
description="Voici un aperçu de votre activité sur les réseaux sociaux."
|
||||
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={`
|
||||
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 />
|
||||
|
||||
<OngoingReels />
|
||||
|
||||
<RecentPublications />
|
||||
|
||||
<ManagedPages />
|
||||
<div className="space-y-6">
|
||||
<StatsCards />
|
||||
<OngoingReels />
|
||||
<div className="grid gap-6 xl:grid-cols-5">
|
||||
<div className="xl:col-span-3">
|
||||
<RecentPublications />
|
||||
</div>
|
||||
<div className="xl:col-span-2">
|
||||
<ManagedPages />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+187
-152
@@ -1,9 +1,14 @@
|
||||
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 { Clock, CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard } from "lucide-react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CheckCircle, XCircle, RotateCw, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Card } 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 { Button } from "@/components/ui/button";
|
||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||
@@ -22,12 +27,32 @@ function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boole
|
||||
return (
|
||||
!!scheduledPost.scheduledAt &&
|
||||
new Date(scheduledPost.scheduledAt) <= new Date() &&
|
||||
!scheduledPost.publishedAt
|
||||
!scheduledPost.publishedAt &&
|
||||
// Un échec ne bouge plus avant sa prochaine tentative (minutes ou heures)
|
||||
!scheduledPost.error
|
||||
);
|
||||
}
|
||||
|
||||
/** Échec dont le scheduler fera une nouvelle tentative automatique. */
|
||||
function isRetrying(scheduledPost: ScheduledPostWithRelations): boolean {
|
||||
return !!scheduledPost.error && !scheduledPost.publishedAt && !!scheduledPost.nextAttemptAt;
|
||||
}
|
||||
|
||||
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() {
|
||||
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 [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[]; generationStatus?: string; generationProgress?: number }>({ postText: '', mediaIds: [], allMedia: [] });
|
||||
const { toast } = useToast();
|
||||
@@ -81,11 +106,11 @@ export default function History() {
|
||||
|
||||
const getPostTypeIcon = (postType: string) => {
|
||||
if (postType === 'feed') {
|
||||
return <ImageIcon className="w-4 h-4" />;
|
||||
return <ImageIcon className="h-3.5 w-3.5" />;
|
||||
} else if (postType === 'story') {
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
return <Smartphone className="h-3.5 w-3.5" />;
|
||||
} else if (postType === 'reel') {
|
||||
return <Clapperboard className="w-4 h-4" />;
|
||||
return <Clapperboard className="h-3.5 w-3.5" />;
|
||||
} else if (postType === 'both') {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
@@ -97,155 +122,166 @@ export default function History() {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Un échec avec un nouvel essai programmé est encore « en cours »
|
||||
const statusOf = (sp: ScheduledPostWithRelations): StatusFilter =>
|
||||
sp.publishedAt && !sp.error
|
||||
? "published"
|
||||
: sp.error && !isRetrying(sp)
|
||||
? "failed"
|
||||
: "pending";
|
||||
|
||||
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 (
|
||||
<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)}
|
||||
<>
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={HistoryIcon}
|
||||
title="Historique"
|
||||
description="Toutes les publications envoyées et leur résultat."
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">Historique</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Consultez l'historique de vos publications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-card rounded-2xl border border-border/50 animate-pulse" />
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => { setStatusFilter(v as StatusFilter); setVisibleCount(PAGE_SIZE); }}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none sm:w-auto">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
) : publishedPosts.length === 0 ? (
|
||||
<Card className="rounded-2xl border-border/50 border-dashed shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted/30 flex items-center justify-center mb-6">
|
||||
<HistoryIcon className="w-10 h-10 text-muted-foreground opacity-50" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">Aucune publication</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
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;
|
||||
</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>
|
||||
|
||||
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>
|
||||
<Badge
|
||||
className={`${hasError
|
||||
? 'bg-destructive/20 text-destructive hover:bg-destructive/30'
|
||||
: isPending
|
||||
? '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...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Publié
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
<span className="font-medium capitalize">
|
||||
{scheduledPost.postType === 'feed' ? 'Feed' :
|
||||
scheduledPost.postType === 'story' ? 'Story' :
|
||||
scheduledPost.postType === 'reel' ? 'Reel' : 'Feed & Story'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{scheduledPost.scheduledAt
|
||||
? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
|
||||
: 'Date inconnue'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : publishedPosts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={HistoryIcon}
|
||||
title="Aucune publication"
|
||||
description="Vos publications apparaîtront ici une fois envoyées."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/new">Créer une publication</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : filteredPosts.length === 0 ? (
|
||||
<EmptyState icon={Search} title="Aucun résultat" description="Essayez un autre mot-clé ou un autre filtre." />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visiblePosts.map((scheduledPost) => {
|
||||
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>
|
||||
) : isRetrying(scheduledPost) ? (
|
||||
<Badge variant="warning" className="gap-1"><RotateCw className="h-3 w-3" /> Nouvel essai</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="outline" className="text-xs bg-gradient-to-r from-primary/5 to-secondary/5 border-primary/20">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Généré par IA
|
||||
</Badge>
|
||||
<Badge variant="muted" className="gap-1"><Sparkles className="h-3 w-3" /> IA</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{scheduledPost.post?.content || <span className="italic">Aucun texte</span>}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getPostTypeIcon(scheduledPost.postType)}
|
||||
{scheduledPost.postType === "feed"
|
||||
? "Feed"
|
||||
: scheduledPost.postType === "story"
|
||||
? "Story"
|
||||
: scheduledPost.postType === "reel"
|
||||
? "Reel"
|
||||
: "Feed & Story"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{scheduledPost.scheduledAt
|
||||
? format(new Date(scheduledPost.scheduledAt), "d MMM yyyy 'à' HH:mm", { locale: fr })
|
||||
: "Date inconnue"}
|
||||
</span>
|
||||
</div>
|
||||
{scheduledPost.error && (
|
||||
<p className="mt-2 rounded-md bg-destructive/10 px-2.5 py-1.5 text-xs text-destructive">{scheduledPost.error}</p>
|
||||
)}
|
||||
{scheduledPost.error && !scheduledPost.publishedAt && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{scheduledPost.nextAttemptAt
|
||||
? `Nouvelle tentative automatique le ${format(new Date(scheduledPost.nextAttemptAt), "d MMM yyyy 'à' HH:mm", { locale: fr })}`
|
||||
: scheduledPost.attempts > 0
|
||||
? "Plus de nouvelle tentative automatique : reprogrammez la publication pour réessayer."
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
</Page>
|
||||
|
||||
<PreviewModal
|
||||
open={previewModalOpen}
|
||||
@@ -259,7 +295,6 @@ export default function History() {
|
||||
generationStatus={previewData.generationStatus}
|
||||
generationProgress={previewData.generationProgress}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 { Slider } from "@/components/ui/slider";
|
||||
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";
|
||||
|
||||
interface Ribbon {
|
||||
@@ -39,7 +41,6 @@ interface Logo {
|
||||
}
|
||||
|
||||
export default function ImageEditor() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState<any>(null);
|
||||
|
||||
const [ribbon, setRibbon] = useState<Ribbon>({
|
||||
@@ -119,7 +120,7 @@ export default function ImageEditor() {
|
||||
}, [selectedMedia, ribbon, priceBadge, logo]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Page width="wide">
|
||||
<style>{`
|
||||
/* Triangle ribbon - auto-centered text */
|
||||
.ribbon-container {
|
||||
@@ -211,45 +212,20 @@ export default function ImageEditor() {
|
||||
transform: translate(50%, -50%) rotate(45deg) translateY(-20px);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{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-[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>
|
||||
<PageHeader
|
||||
icon={Wand2}
|
||||
title="Éditeur d'images"
|
||||
description="Ajoutez un ruban, un prix ou votre logo sur une image, puis enregistrez-la dans la médiathèque."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
|
||||
{/* Left Column - Controls */}
|
||||
<div className="space-y-6">
|
||||
{/* Image Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
Sélectionner une image
|
||||
</CardTitle>
|
||||
<CardTitle>1. Choisissez une image</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
|
||||
@@ -260,7 +236,7 @@ export default function ImageEditor() {
|
||||
<div
|
||||
key={media.id}
|
||||
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)}
|
||||
data-testid={`select-image-${media.id}`}
|
||||
@@ -274,9 +250,17 @@ export default function ImageEditor() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Aucune image disponible. Uploadez des images dans la médiathèque.
|
||||
</p>
|
||||
<EmptyState
|
||||
compact
|
||||
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>
|
||||
</Card>
|
||||
@@ -284,7 +268,7 @@ export default function ImageEditor() {
|
||||
{/* Ribbon Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ruban promotionnel</CardTitle>
|
||||
<CardTitle>2. Ruban promotionnel</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -365,7 +349,7 @@ export default function ImageEditor() {
|
||||
{/* Price Badge */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badge prix</CardTitle>
|
||||
<CardTitle>3. Prix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -450,7 +434,7 @@ export default function ImageEditor() {
|
||||
{/* Logo */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Logo</CardTitle>
|
||||
<CardTitle>4. Logo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -526,7 +510,7 @@ export default function ImageEditor() {
|
||||
<CardContent>
|
||||
{selectedMedia ? (
|
||||
<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">
|
||||
<img
|
||||
src={previewUrl || selectedMedia.originalUrl}
|
||||
@@ -606,7 +590,7 @@ export default function ImageEditor() {
|
||||
|
||||
<button
|
||||
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"
|
||||
title="Désélectionner l'image"
|
||||
>
|
||||
@@ -616,26 +600,25 @@ export default function ImageEditor() {
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
variant="brand"
|
||||
data-testid="button-save"
|
||||
onClick={() => saveImageMutation.mutate()}
|
||||
disabled={saveImageMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveImageMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
<Save className="w-4 h-4" />
|
||||
{saveImageMutation.isPending ? "Enregistrement…" : "Enregistrer dans la médiathèque"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square flex items-center justify-center bg-muted rounded-lg">
|
||||
<p className="text-muted-foreground">Sélectionnez une image</p>
|
||||
<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">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
Choisissez une image pour voir l'aperçu
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+62
-43
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
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() {
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -56,38 +56,59 @@ export default function Login() {
|
||||
};
|
||||
|
||||
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="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* 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">
|
||||
<CardHeader className="space-y-4 text-center pt-8 pb-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">
|
||||
<Sparkles className="w-10 h-10 text-white" />
|
||||
{/* Formulaire */}
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 lg:hidden">
|
||||
<Logo size={44} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Social Flow
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base mt-3">
|
||||
Connectez-vous à votre compte pour gérer vos publications
|
||||
</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">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Connexion</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Connectez-vous pour gérer vos publications.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" data-testid="label-username">
|
||||
Nom d'utilisateur
|
||||
</Label>
|
||||
<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
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
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
|
||||
disabled={isLoading}
|
||||
data-testid="input-username"
|
||||
@@ -95,19 +116,20 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="password" className="text-sm font-semibold" data-testid="label-password">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" data-testid="label-password">
|
||||
Mot de passe
|
||||
</Label>
|
||||
<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
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
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
|
||||
disabled={isLoading}
|
||||
data-testid="input-password"
|
||||
@@ -115,28 +137,25 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
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"
|
||||
disabled={isLoading}
|
||||
data-testid="button-login"
|
||||
>
|
||||
{isLoading ? "Connexion en cours..." : "Se connecter"}
|
||||
<Button type="submit" variant="brand" size="lg" className="w-full" disabled={isLoading} data-testid="button-login">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{isLoading ? "Connexion en cours…" : "Se connecter"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showDefaultPassword && (
|
||||
<div className="mt-8 text-center p-4 rounded-xl bg-warning/10 border border-warning/20">
|
||||
<p className="text-sm text-muted-foreground mb-2">Identifiants par défaut :</p>
|
||||
<p className="font-semibold text-foreground">admin / admin</p>
|
||||
<p className="text-xs mt-3 text-warning flex items-center justify-center gap-2">
|
||||
<span className="text-base">⚠️</span>
|
||||
Changez ce mot de passe après la première connexion
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3 rounded-xl border border-warning/30 bg-warning/10 p-4 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<p className="text-foreground">
|
||||
Identifiants par défaut : <span className="font-semibold">admin / admin</span>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">Changez ce mot de passe après la première connexion.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-35
@@ -1,41 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Images } from "lucide-react";
|
||||
import { Page } from "@/components/layout/app-shell";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import MediaUpload from "@/components/media-upload";
|
||||
|
||||
export default function Media() {
|
||||
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 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 />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={Images}
|
||||
title="Médiathèque"
|
||||
description="Toutes vos images et vidéos, prêtes à être publiées."
|
||||
/>
|
||||
<MediaUpload />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import AudioAdmin from "../audio-admin";
|
||||
export default AudioAdmin;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +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";
|
||||
|
||||
/** Post dont le statut peut changer d'un instant à l'autre. */
|
||||
function isChanging(post: any): boolean {
|
||||
if (post?.generationStatus === 'pending' || post?.generationStatus === 'processing') {
|
||||
return true;
|
||||
}
|
||||
return post?.status === 'scheduled' && !!post?.scheduledFor && new Date(post.scheduledFor) <= new Date();
|
||||
}
|
||||
|
||||
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'],
|
||||
// Un post planifié pour la semaine prochaine ne bougera pas d'ici une
|
||||
// seconde : on ne suit de près que ce dont le statut peut basculer
|
||||
// maintenant (génération en cours, ou heure de publication dépassée).
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some(isChanging) ? 3000 : 30000,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,800 +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, getErrorMessage } 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}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nombre de médias récents proposés à la sélection. */
|
||||
const RECENT_MEDIA_COUNT = 12;
|
||||
|
||||
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||
function startOfToday(): Date {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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'],
|
||||
});
|
||||
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[]>({
|
||||
queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
|
||||
});
|
||||
|
||||
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, RECENT_MEDIA_COUNT);
|
||||
}, [allMedia, recentUploads]);
|
||||
|
||||
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
|
||||
// passé n'a aucune influence sur une planification à venir.
|
||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
|
||||
});
|
||||
|
||||
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: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "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}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,653 +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 { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload";
|
||||
import { srtText } from "@shared/srt";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
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('');
|
||||
// Fichier SRT : remplace le texte libre, la voix suit son minutage
|
||||
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// État audio preview
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
|
||||
const [stabilize, setStabilize] = useState(false);
|
||||
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
|
||||
const [showLogo, setShowLogo] = 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() || srtFile) {
|
||||
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, srtFile]);
|
||||
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
// Destinations acceptant une vidéo verticale
|
||||
const facebookPages = pages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
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: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après."
|
||||
: "Suivez l'avancement sur l'accueil.",
|
||||
});
|
||||
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: srtFile ? undefined : overlayText,
|
||||
srtCues: srtFile?.cues,
|
||||
description: srtFile ? srtText(srtFile.cues) : overlayText,
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
musicVolume: musicVolume[0] / 100,
|
||||
stabilize: stabilize,
|
||||
drawText: true,
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
enableEndingEffect,
|
||||
showLogo,
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
{!srtFile && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!srtFile && 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>
|
||||
<SrtUpload value={srtFile} onChange={setSrtFile} />
|
||||
{srtFile ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
|
||||
</p>
|
||||
) : (
|
||||
<Textarea
|
||||
value={overlayText}
|
||||
onChange={(e) => setOverlayText(e.target.value)}
|
||||
placeholder="Texte sur la vidéo..."
|
||||
className="text-lg"
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 mt-3">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="show-logo"
|
||||
checked={showLogo}
|
||||
onCheckedChange={setShowLogo}
|
||||
/>
|
||||
<Label htmlFor="show-logo" className="font-medium cursor-pointer">
|
||||
Afficher le logo sur la vidéo
|
||||
</Label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="mt-2">
|
||||
<VoicePicker
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{syncInfo && !srtFile && (
|
||||
<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">
|
||||
{selectedVideo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="video"
|
||||
videoUrl={selectedVideo.originalUrl}
|
||||
text={srtFile ? srtText(srtFile.cues) : overlayText}
|
||||
srtCues={srtFile?.cues}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={selectedTrack?.previewUrl}
|
||||
musicVolume={musicVolume[0] / 100}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
||||
endingEffect={enableEndingEffect}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune page Facebook ni compte TikTok connecté.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
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 { ClientSocialPage } from "@shared/schema";
|
||||
import {
|
||||
ConnectFacebookButton,
|
||||
RefreshTokenButton,
|
||||
TokenAlertBanner,
|
||||
TokenHealthPanel,
|
||||
} from "@/components/facebook-token-status";
|
||||
|
||||
/**
|
||||
* 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 ConnectTiktokButton() {
|
||||
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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full min-h-[48px]"
|
||||
onClick={handleClick}
|
||||
data-testid="button-connect-tiktok"
|
||||
>
|
||||
<SiTiktok className="w-5 h-5 mr-2" />
|
||||
Connecter un compte TikTok
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PagesManagementMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 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'],
|
||||
});
|
||||
|
||||
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, Instagram et vos comptes TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
<ConnectFacebookButton className="w-full min-h-[48px]" />
|
||||
<ConnectTiktokButton />
|
||||
<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>
|
||||
) : page.platform === 'tiktok' ? (
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<SiTiktok 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>
|
||||
|
||||
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||
<TokenHealthPanel page={page} />
|
||||
|
||||
{page.platform !== 'tiktok' && (
|
||||
<div className="flex">
|
||||
<RefreshTokenButton page={page} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{page.platform === 'tiktok' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 min-h-[44px]"
|
||||
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||
data-testid={`button-reconnect-page-${page.id}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reconnecter
|
||||
</Button>
|
||||
) : (
|
||||
<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: ClientSocialPage | 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>
|
||||
);
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
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 [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
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'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
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: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), 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 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);
|
||||
setRenderProgress(0);
|
||||
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);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
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 {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
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="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</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. Destinations */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</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" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><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">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].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>
|
||||
);
|
||||
}
|
||||
@@ -1,585 +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'],
|
||||
// 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,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+495
-350
@@ -1,7 +1,16 @@
|
||||
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 { 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 {
|
||||
DndContext,
|
||||
@@ -20,20 +29,16 @@ import {
|
||||
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, getErrorMessage } from "@/lib/queryClient";
|
||||
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 { DateTimePicker } from "@/components/datetime-picker";
|
||||
|
||||
@@ -68,7 +73,7 @@ function SortableMediaItem({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -86,15 +91,15 @@ function SortableMediaItem({
|
||||
</button>
|
||||
{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}
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
{...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>
|
||||
</>
|
||||
)}
|
||||
@@ -105,15 +110,29 @@ function SortableMediaItem({
|
||||
/** Nombre de médias récents proposés à la sélection. */
|
||||
const RECENT_MEDIA_COUNT = 12;
|
||||
|
||||
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||
function startOfToday(): Date {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
const STEPS: StepDef[] = [
|
||||
{ id: "media", label: "Médias", icon: ImageIcon },
|
||||
{ id: "text", label: "Texte", icon: Type },
|
||||
{ id: "targets", label: "Diffusion", icon: Users },
|
||||
{ id: "schedule", label: "Planification", icon: Calendar },
|
||||
];
|
||||
|
||||
const FORMATS = [
|
||||
{ value: 'feed', label: "Fil d'actualité", description: "Publication classique", icon: LayoutGrid },
|
||||
{ value: 'story', label: "Story", description: "Visible 24 h, média requis", icon: Smartphone },
|
||||
{ value: 'both', label: "Fil + Story", description: "Les deux à la fois", icon: Layers },
|
||||
] as const;
|
||||
|
||||
function SummaryRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className={`text-right font-medium ${ok ? 'text-foreground' : 'text-muted-foreground'}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewPost() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -126,6 +145,9 @@ export default function NewPost() {
|
||||
const [postText, setPostText] = useState('');
|
||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [scheduleMode, setScheduleMode] = useState<'now' | 'later'>('now');
|
||||
|
||||
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
||||
// vidéos et se pilote depuis la création de reel.
|
||||
@@ -153,12 +175,6 @@ export default function NewPost() {
|
||||
.slice(0, RECENT_MEDIA_COUNT);
|
||||
}, [allMedia]);
|
||||
|
||||
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
|
||||
// passé n'a aucune influence sur une planification à venir.
|
||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
@@ -322,10 +338,6 @@ export default function NewPost() {
|
||||
|
||||
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>) => {
|
||||
@@ -384,112 +396,141 @@ export default function NewPost() {
|
||||
}
|
||||
};
|
||||
|
||||
const contentReady = postText.trim().length > 0 || selectedMedia.length > 0;
|
||||
const needsMedia = postType === 'story' || postType === 'both';
|
||||
const targetsReady = selectedPages.length > 0 && (!needsMedia || selectedMedia.length > 0);
|
||||
|
||||
const lockedReason = (index: number): string | null => {
|
||||
if (index >= 2 && !contentReady) return "Ajoutez un média ou un texte";
|
||||
if (index >= 3 && !targetsReady) return selectedPages.length === 0 ? "Choisissez au moins une page" : "Les stories nécessitent un média";
|
||||
return null;
|
||||
};
|
||||
|
||||
const goTo = (index: number) => {
|
||||
const reason = lockedReason(index);
|
||||
if (reason) {
|
||||
toast({ title: "Étape incomplète", description: reason, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setStep(index);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const formatLabel = postType === 'feed' ? "Fil d'actualité" : postType === 'story' ? "Story" : "Fil + Story";
|
||||
const selectedPageNames = pages.filter(p => selectedPages.includes(p.id)).map(p => p.pageName);
|
||||
|
||||
const toggleMedia = (mediaId: string) => {
|
||||
if (selectedMedia.includes(mediaId)) {
|
||||
setSelectedMedia(prev => prev.filter(id => id !== mediaId));
|
||||
} else if (selectedMedia.length < 10) {
|
||||
setSelectedMedia(prev => [...prev, mediaId]);
|
||||
} else {
|
||||
toast({
|
||||
title: "Limite atteinte",
|
||||
description: "Maximum 10 médias par publication",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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)}
|
||||
<>
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={PenSquare}
|
||||
title="Nouvelle publication"
|
||||
description="Préparez une publication photo ou vidéo pour Facebook et Instagram."
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Stepper steps={STEPS} current={step} lockedReason={lockedReason} onStepClick={goTo} />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<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">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
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>
|
||||
<CardTitle>Média</CardTitle>
|
||||
<CardDescription>Sélectionnez ou uploadez une image/vidéo</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="lg:hidden"
|
||||
data-testid="button-camera-capture"
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="button-upload-new-media"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* ÉTAPE 1 : Médias */}
|
||||
{step === 0 && (
|
||||
<Card className="fade-in">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle>Choisissez vos médias</CardTitle>
|
||||
<CardDescription>Jusqu'à 10 photos ou vidéos. Facultatif pour une publication texte.</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="lg:hidden"
|
||||
aria-label="Prendre une photo"
|
||||
data-testid="button-camera-capture"
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="button-upload-new-media"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Importer
|
||||
</Button>
|
||||
</div>
|
||||
</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' : ''}`}>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`rounded-xl transition-colors ${isDragActive ? 'bg-primary/5 ring-2 ring-primary ring-offset-2 ring-offset-card' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{mediaList.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground mb-2">
|
||||
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="lg:hidden"
|
||||
data-testid="button-camera-first"
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Prendre une photo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
data-testid="button-upload-first-media"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Uploader une photo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
compact
|
||||
icon={ImageIcon}
|
||||
title={isDragActive ? "Déposez votre fichier ici" : "Aucun média pour l'instant"}
|
||||
description="Glissez-déposez une image ou une vidéo, ou importez-la depuis votre appareil."
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="lg:hidden"
|
||||
data-testid="button-camera-first"
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
Prendre une photo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={open}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
data-testid="button-upload-first-media"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Importer un fichier
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{selectedMedia.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Photos sélectionnées ({selectedMedia.length}/10)
|
||||
<div className="mb-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<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>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -500,7 +541,7 @@ export default function NewPost() {
|
||||
items={selectedMedia}
|
||||
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) => {
|
||||
const media = mediaList.find(m => m.id === mediaId);
|
||||
if (!media) return null;
|
||||
@@ -522,253 +563,359 @@ export default function NewPost() {
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Toutes les photos ({mediaList.length})
|
||||
</div>
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{mediaList.map((media) => {
|
||||
const isSelected = selectedMedia.includes(media.id);
|
||||
const isVideo = media.type === 'video';
|
||||
<p className="mb-2 text-sm font-medium">Médias récents</p>
|
||||
<div className="grid max-h-[460px] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4">
|
||||
{mediaList.map((media) => {
|
||||
const isSelected = selectedMedia.includes(media.id);
|
||||
const isVideo = media.type === 'video';
|
||||
const order = selectedMedia.indexOf(media.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
// Remove from selection
|
||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||
} 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'
|
||||
}`}
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={isVideo ? 'video' : 'image'}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
||||
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => toggleMedia(media.id)}
|
||||
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-all ${isSelected
|
||||
? 'border-primary'
|
||||
: 'border-transparent hover:border-primary/40'
|
||||
}`}
|
||||
aria-pressed={isSelected}
|
||||
data-testid={`button-select-media-${media.id}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.facebookFeedUrl || media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type={isVideo ? 'video' : 'image'}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-primary/20">
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Contenu</CardTitle>
|
||||
<CardDescription>Informations du produit ou message</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="productInfo">Informations du produit</Label>
|
||||
<Textarea
|
||||
id="productInfo"
|
||||
value={productInfo}
|
||||
onChange={(e) => setProductInfo(e.target.value)}
|
||||
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
|
||||
rows={6}
|
||||
data-testid="input-product-info"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateText}
|
||||
disabled={generateTextMutation.isPending}
|
||||
className="w-full"
|
||||
data-testid="button-generate-text"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{generateTextMutation.isPending ? 'Génération...' : 'Générer le texte IA'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generatedVariants.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Variations générées par l'IA</CardTitle>
|
||||
<CardDescription>Cliquez sur "Utiliser" pour remplir le texte ci-dessous</CardDescription>
|
||||
{/* ÉTAPE 2 : Texte */}
|
||||
{step === 1 && (
|
||||
<div className="fade-in space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rédigez votre texte</CardTitle>
|
||||
<CardDescription>Écrivez-le vous-même ou laissez l'IA vous proposer des versions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{generatedVariants.map((variant, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<CardContent className="space-y-4">
|
||||
<Collapsible open={aiOpen} onOpenChange={setAiOpen} className="rounded-xl border border-primary/20 bg-primary/5">
|
||||
<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
|
||||
id="productInfo"
|
||||
value={productInfo}
|
||||
onChange={(e) => setProductInfo(e.target.value)}
|
||||
placeholder="Ex. : Produit : Chaise en rotin — Prix : 49 € — Caractéristiques : légère, résistante"
|
||||
rows={4}
|
||||
className="bg-card"
|
||||
data-testid="input-product-info"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleGenerateText}
|
||||
disabled={generateTextMutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="button-generate-text"
|
||||
>
|
||||
{generateTextMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{generateTextMutation.isPending ? 'Génération…' : 'Générer des propositions'}
|
||||
</Button>
|
||||
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{generatedVariants.map((variant, index) => (
|
||||
<div key={index} className="rounded-lg border bg-card p-3">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{variant.variant || `Proposition ${index + 1}`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={postText === variant.text ? "secondary" : "default"}
|
||||
className="h-8"
|
||||
onClick={() => handleUseVariant(variant.text)}
|
||||
data-testid={`button-use-variant-${index}`}
|
||||
>
|
||||
{postText === variant.text ? <><Check className="h-3.5 w-3.5" /> Utilisée</> : "Utiliser"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{variant.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUseVariant(variant.text)}
|
||||
data-testid={`button-use-variant-${index}`}
|
||||
>
|
||||
Utiliser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="post-text">Texte de la publication</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{postText.length} caractères</span>
|
||||
</div>
|
||||
))}
|
||||
<Textarea
|
||||
id="post-text"
|
||||
value={postText}
|
||||
onChange={(e) => setPostText(e.target.value)}
|
||||
placeholder="Écrivez votre texte ici…"
|
||||
rows={9}
|
||||
data-testid="textarea-post-text"
|
||||
/>
|
||||
{selectedMedia.length > 0 && !postText.trim() && (
|
||||
<p className="text-xs text-muted-foreground">Le texte est facultatif puisque vous avez ajouté un média.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Texte de la publication</CardTitle>
|
||||
<CardDescription>Écrivez ou générez le texte de votre publication</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={8}
|
||||
data-testid="textarea-post-text"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* ÉTAPE 3 : Diffusion */}
|
||||
{step === 2 && (
|
||||
<div className="fade-in space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Où publier ?</CardTitle>
|
||||
<CardDescription>Sélectionnez une ou plusieurs pages.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pages.length === 0 ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={Link2}
|
||||
title="Aucune page connectée"
|
||||
description="Connectez une page Facebook ou Instagram pour pouvoir publier."
|
||||
action={
|
||||
<Button size="sm" onClick={() => navigate('/pages')} data-testid="link-add-pages">
|
||||
Connecter un compte
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{pages.map((page) => {
|
||||
const checked = selectedPages.includes(page.id);
|
||||
return (
|
||||
<label
|
||||
key={page.id}
|
||||
htmlFor={`page-${page.id}`}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${checked ? 'border-primary bg-primary/5' : 'hover:bg-accent'}`}
|
||||
>
|
||||
<PlatformIcon platform={page.platform} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{page.pageName}</span>
|
||||
<span className="block text-xs text-muted-foreground">{platformLabel(page.platform)}</span>
|
||||
</span>
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedPages([...selectedPages, page.id]);
|
||||
} else {
|
||||
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||
}
|
||||
}}
|
||||
data-testid={`checkbox-page-${page.id}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-8">
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Pages cibles</CardTitle>
|
||||
<CardDescription>Sélectionnez les pages où publier</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-2">Aucune page connectée</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => navigate('/pages')}
|
||||
data-testid="link-add-pages"
|
||||
>
|
||||
Ajouter des pages
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pages.map((page) => (
|
||||
<div key={page.id} className="flex items-center space-x-2">
|
||||
<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));
|
||||
}
|
||||
}}
|
||||
data-testid={`checkbox-page-${page.id}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`page-${page.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
|
||||
>
|
||||
{page.pageName} ({page.platform})
|
||||
</label>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Format</CardTitle>
|
||||
<CardDescription>Comment la publication apparaîtra.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={postType}
|
||||
onValueChange={(value) => setPostType(value as 'feed' | 'story' | 'both')}
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-3"
|
||||
data-testid="select-post-type"
|
||||
>
|
||||
{FORMATS.map(({ value, label, description, icon: Icon }) => (
|
||||
<label
|
||||
key={value}
|
||||
htmlFor={`format-${value}`}
|
||||
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'}`}
|
||||
data-testid={`option-${value}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className={`h-5 w-5 ${postType === value ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<RadioGroupItem value={value} id={`format-${value}`} className="hidden sm:block" />
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ÉTAPE 4 : Planification */}
|
||||
{step === 3 && (
|
||||
<Card className="fade-in">
|
||||
<CardHeader>
|
||||
<CardTitle>Quand publier ?</CardTitle>
|
||||
<CardDescription>Publiez tout de suite ou choisissez une date.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<RadioGroup
|
||||
value={scheduleMode}
|
||||
onValueChange={(value) => {
|
||||
setScheduleMode(value as 'now' | 'later');
|
||||
if (value === 'now') setScheduledDate(undefined);
|
||||
}}
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{([
|
||||
{ value: 'now', label: 'Publier maintenant', description: 'Envoi immédiat après validation', icon: Zap },
|
||||
{ value: 'later', label: 'Programmer', description: 'Choisir une date et une heure', icon: CalendarClock },
|
||||
] as const).map(({ value, label, description, icon: Icon }) => (
|
||||
<label
|
||||
key={value}
|
||||
htmlFor={`when-${value}`}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors ${scheduleMode === value ? 'border-primary bg-primary/5' : 'hover:bg-accent'}`}
|
||||
data-testid={`option-schedule-${value}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${scheduleMode === value ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
<RadioGroupItem value={value} id={`when-${value}`} />
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{scheduleMode === 'later' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Date et heure</Label>
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir une date"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Type de publication</CardTitle>
|
||||
<CardDescription>Choisissez où publier</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postType">Format</Label>
|
||||
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
|
||||
<SelectTrigger id="postType" 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Planification</CardTitle>
|
||||
<CardDescription>Programmez la publication (optionnel)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Label>Date et heure</Label>
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
occupiedDates={scheduledPosts
|
||||
.filter(post => post.scheduledAt)
|
||||
.map(post => new Date(post.scheduledAt!))}
|
||||
placeholder="Publier immédiatement"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sélectionnez une date pour planifier, sinon publication immédiate
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
onClick={() => setPreviewModalOpen(true)}
|
||||
disabled={createPostMutation.isPending}
|
||||
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg"
|
||||
size="lg"
|
||||
data-testid="button-preview-post"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Prévisualiser et publier
|
||||
</Button>
|
||||
</div>
|
||||
<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
|
||||
onClick={() => {
|
||||
if (scheduleMode === 'later' && !scheduledDate) {
|
||||
toast({ title: "Date manquante", description: "Choisissez une date ou publiez maintenant.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setPreviewModalOpen(true);
|
||||
}}
|
||||
disabled={createPostMutation.isPending}
|
||||
variant="brand"
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="button-preview-post"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Prévisualiser et {scheduleMode === 'later' ? 'programmer' : 'publier'}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</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 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>
|
||||
</main>
|
||||
</Page>
|
||||
|
||||
<Dialog open={uploadMutation.isPending}>
|
||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||
@@ -776,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">
|
||||
<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 className="text-lg font-semibold text-foreground mb-1">
|
||||
Import 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.
|
||||
Votre fichier est en cours d'envoi et de traitement.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -797,6 +942,6 @@ export default function NewPost() {
|
||||
onPublish={handleCreatePost}
|
||||
isPublishing={createPostMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+519
-614
File diff suppressed because it is too large.
Load diff
@@ -1,21 +1,23 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Compass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogoMark } from "@/components/brand/logo";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-background p-6 text-center">
|
||||
<LogoMark size={44} />
|
||||
<div className="mt-8 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Compass className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-primary">Erreur 404</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight">Page introuvable</h1>
|
||||
<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>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href="/">Retour au tableau de bord</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+212
-195
@@ -1,12 +1,31 @@
|
||||
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 { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Plus, Trash2, RefreshCw, Bug, Code, ChevronDown, HelpCircle, KeyRound, Link2, MoreVertical } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
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 { 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 { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -14,16 +33,16 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import type { ClientSocialPage } from "@shared/schema";
|
||||
import {
|
||||
ConnectFacebookButton,
|
||||
useConnectFacebook,
|
||||
RefreshTokenButton,
|
||||
TokenAlertBanner,
|
||||
TokenHealthPanel,
|
||||
} from "@/components/facebook-token-status";
|
||||
|
||||
export default function PagesManagement() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||
const [pageToDelete, setPageToDelete] = useState<ClientSocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||
@@ -56,8 +75,8 @@ export default function PagesManagement() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: "Page supprimée",
|
||||
description: "La page a été retirée avec succès",
|
||||
title: "Compte déconnecté",
|
||||
description: "Le compte a été retiré de Social Flow",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
@@ -69,180 +88,189 @@ export default function PagesManagement() {
|
||||
},
|
||||
});
|
||||
|
||||
const connectFacebook = useConnectFacebook();
|
||||
const connectTiktok = useConnectTiktok();
|
||||
|
||||
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-[1600px] mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectFacebookButton />
|
||||
<ConnectTiktokButton />
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</div>
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
</div>
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
{/* Permissions required notice */}
|
||||
<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">Connectez vos pages plutôt que de coller un jeton</p>
|
||||
<p className="text-muted-foreground mb-2">
|
||||
« Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
|
||||
ils n'expirent pas et SocialFlow 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é manuellement.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Le jeton doit couvrir : <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>
|
||||
</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 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="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" />
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={Link2}
|
||||
title="Comptes connectés"
|
||||
description="Les pages Facebook, Instagram et comptes TikTok sur lesquels Social Flow publie."
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button data-testid="button-connect-account">
|
||||
<Plus className="h-4 w-4" /> Connecter un compte <ChevronDown className="h-4 w-4 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuItem onClick={connectFacebook} className="items-start gap-3 py-2.5" data-testid="button-connect-facebook">
|
||||
<PlatformIcon platform="facebook" size="sm" />
|
||||
<div>
|
||||
<p className="font-medium">Facebook & Instagram</p>
|
||||
<p className="text-xs text-muted-foreground">Recommandé · connexion permanente</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Aucune page connectée</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Commencez par connecter vos pages Facebook et Instagram
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)} data-testid="button-add-first-page">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Ajouter une page
|
||||
</Button>
|
||||
</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} />
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="skeleton h-52 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Link2}
|
||||
title="Aucun compte connecté"
|
||||
description="Connectez vos pages Facebook (et les comptes Instagram associés) ou un compte TikTok pour commencer à publier."
|
||||
action={
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button onClick={connectFacebook} data-testid="button-add-first-page">
|
||||
<PlatformIcon platform="facebook" size="sm" className="h-5 w-5 bg-transparent text-current" />
|
||||
Connecter Facebook
|
||||
</Button>
|
||||
<Button variant="outline" onClick={connectTiktok}>
|
||||
<PlatformIcon platform="tiktok" size="sm" className="h-5 w-5 bg-transparent" />
|
||||
Connecter TikTok
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{pages.map((page) => (
|
||||
<Card key={page.id} className="flex flex-col" data-testid={`card-page-${page.id}`}>
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
||||
<PlatformIcon platform={page.platform} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate">{page.pageName}</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{platformLabel(page.platform)} · <span className="font-mono text-xs">{page.pageId}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="shrink-0 text-muted-foreground" aria-label="Plus d'actions" data-testid={`button-page-menu-${page.id}`}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{page.platform === "tiktok" ? (
|
||||
<DropdownMenuItem onClick={() => { window.location.href = "/api/tiktok/connect"; }} data-testid={`button-reconnect-page-${page.id}`}>
|
||||
<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}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Déconnecter
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages.map((page) => (
|
||||
<Card key={page.id} className="rounded-2xl border-border/50 shadow-lg" data-testid={`card-page-${page.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{page.platform === 'facebook' ? (
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Facebook className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : page.platform === 'tiktok' ? (
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
|
||||
<SiTiktok 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">
|
||||
<Instagram className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">{page.pageName}</CardTitle>
|
||||
<CardDescription className="capitalize">
|
||||
{page.platform}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
ID: {page.pageId}
|
||||
</div>
|
||||
|
||||
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||
<TokenHealthPanel page={page} />
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
|
||||
{page.platform === 'tiktok' ? (
|
||||
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||
data-testid={`button-reconnect-page-${page.id}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reconnecter
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingPage(page)}
|
||||
data-testid={`button-edit-page-${page.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(page.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
data-testid={`button-delete-page-${page.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
</CollapsibleContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,7 +278,7 @@ export default function PagesManagement() {
|
||||
* 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 ConnectTiktokButton() {
|
||||
function useConnectTiktok() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: config } = useQuery<{ configured: boolean }>({
|
||||
@@ -269,12 +297,7 @@ function ConnectTiktokButton() {
|
||||
window.location.href = '/api/tiktok/connect';
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
|
||||
<SiTiktok className="w-4 h-4 mr-2" />
|
||||
Connecter un compte TikTok
|
||||
</Button>
|
||||
);
|
||||
return handleClick;
|
||||
}
|
||||
|
||||
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -320,17 +343,11 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
||||
|
||||
return (
|
||||
<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]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajouter une page</DialogTitle>
|
||||
<DialogTitle>Ajouter un compte manuellement</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -411,7 +428,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
||||
>
|
||||
{addMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Ajout...
|
||||
</>
|
||||
) : (
|
||||
@@ -510,7 +527,7 @@ function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null;
|
||||
>
|
||||
{editMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Modification...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
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, getErrorMessage } from "@/lib/queryClient";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
|
||||
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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
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 [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
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'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<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>
|
||||
);
|
||||
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: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), 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 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);
|
||||
setRenderProgress(0);
|
||||
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);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
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 {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
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="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</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. Destinations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
||||
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[300px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</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" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><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">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+277
-270
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } 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 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";
|
||||
@@ -12,10 +13,30 @@ import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
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() {
|
||||
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 [autoPublish, setAutoPublish] = useState(true);
|
||||
const [cloudName, setCloudName] = useState("");
|
||||
@@ -257,7 +278,7 @@ export default function Settings() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||
toast({
|
||||
title: "Logo uploadé",
|
||||
title: "Logo enregistré",
|
||||
description: "Votre logo a été enregistré avec succès",
|
||||
});
|
||||
setLogoFile(null);
|
||||
@@ -265,7 +286,7 @@ export default function Settings() {
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible d'uploader le logo",
|
||||
description: error.message || "Impossible d'importer le logo",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -417,35 +438,32 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={SettingsIcon}
|
||||
title="Paramètres"
|
||||
description="Configurez les services utilisés par Social Flow."
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Tabs value={tab} onValueChange={changeTab} orientation="vertical" className="lg:grid lg:grid-cols-[220px_1fr] lg:gap-8">
|
||||
<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">
|
||||
{SETTINGS_TABS.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="shrink-0 gap-2 lg:justify-start lg:px-3 lg:py-2 lg:data-[state=active]:bg-primary/10 lg:data-[state=active]:text-primary lg:data-[state=active]:shadow-none"
|
||||
data-testid={`tab-settings-${value}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto">
|
||||
<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">
|
||||
<div className="min-w-0">
|
||||
<TabsContent value="general" className="mt-0 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
Notifications
|
||||
@@ -486,8 +504,109 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
{isAdmin && (
|
||||
<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">
|
||||
<Brain className="w-5 h-5" />
|
||||
Configuration OpenRouter (IA)
|
||||
@@ -510,7 +629,7 @@ export default function Settings() {
|
||||
<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
|
||||
<span className="text-success">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -567,9 +686,59 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Gemini TTS */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<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">
|
||||
<Cloud className="w-5 h-5" />
|
||||
Stockage des médias
|
||||
@@ -589,8 +758,8 @@ export default function Settings() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Video className="w-5 h-5" />
|
||||
Configuration FFmpeg API (Reels)
|
||||
@@ -627,7 +796,7 @@ export default function Settings() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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"
|
||||
@@ -648,211 +817,11 @@ export default function Settings() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Gemini TTS */}
|
||||
</TabsContent>
|
||||
<TabsContent value="social" className="mt-0 space-y-6">
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mic className="w-5 h-5" />
|
||||
Google Gemini TTS
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
||||
<Input
|
||||
id="geminiApiKey"
|
||||
type="password"
|
||||
value={geminiApiKey}
|
||||
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
||||
data-testid="input-gemini-api-key"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasExistingGeminiConfig ? (
|
||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
|
||||
) : (
|
||||
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveGeminiMutation.mutate()}
|
||||
disabled={
|
||||
saveGeminiMutation.isPending ||
|
||||
(!hasExistingGeminiConfig && !geminiApiKey)
|
||||
}
|
||||
data-testid="button-save-gemini"
|
||||
className="w-full"
|
||||
>
|
||||
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Image className="w-5 h-5" />
|
||||
Logo de l'entreprise
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Uploadez votre logo pour l'ajouter aux images éditées
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{logoPreview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="relative w-full max-w-xs mx-auto">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo"
|
||||
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
data-testid="button-delete-logo"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
data-testid="button-replace-logo"
|
||||
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload-replace"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
||||
</p>
|
||||
<Label htmlFor="logo-upload" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
||||
data-testid="button-upload-logo"
|
||||
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!cloudinaryConfig && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||
Veuillez d'abord configurer MinIO ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé API configurée et active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKey">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKey"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Facebook className="w-5 h-5" />
|
||||
Facebook
|
||||
@@ -865,7 +834,7 @@ export default function Settings() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{facebookConfig?.configured && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Application Facebook configurée</span>
|
||||
</div>
|
||||
@@ -925,20 +894,20 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SiTiktok className="w-5 h-5" />
|
||||
TikTok
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
|
||||
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
|
||||
connecter les comptes TikTok de chaque magasin depuis la page « Comptes connectés ».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{tiktokConfig?.configured && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-success">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Application TikTok configurée</span>
|
||||
</div>
|
||||
@@ -988,26 +957,64 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="mt-0 space-y-6">
|
||||
{isAdmin && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé API configurée et active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKey">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKey"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+56
-72
@@ -1,17 +1,16 @@
|
||||
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 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";
|
||||
import { Database, Download, Play, Table2 } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export default function SqlAdmin() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [sqlQuery, setSqlQuery] = useState("");
|
||||
const [queryResult, setQueryResult] = useState<any>(null);
|
||||
@@ -75,77 +74,60 @@ export default function SqlAdmin() {
|
||||
};
|
||||
|
||||
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>
|
||||
<Page width="wide">
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
title="Base de données"
|
||||
description="Exécutez des requêtes SQL directement sur la base. À utiliser avec précaution."
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{/* Liste des tables */}
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Tables disponibles</CardTitle>
|
||||
<CardDescription>
|
||||
Cliquez pour insérer dans la requête
|
||||
</CardDescription>
|
||||
<Card className="h-fit lg:sticky lg:top-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Tables</CardTitle>
|
||||
<CardDescription>Cliquez pour pré-remplir la requête</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(tablesData as any)?.tables?.map((table: any) => (
|
||||
<Button
|
||||
key={table.tablename}
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
onClick={() => insertTable(table.tablename)}
|
||||
data-testid={`button-table-${table.tablename}`}
|
||||
>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
{table.tablename}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{(tablesData as any)?.tables?.length ? (
|
||||
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto lg:flex-col lg:flex-nowrap">
|
||||
{(tablesData as any).tables.map((table: any) => (
|
||||
<button
|
||||
key={table.tablename}
|
||||
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"
|
||||
onClick={() => insertTable(table.tablename)}
|
||||
data-testid={`button-table-${table.tablename}`}
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{table.tablename}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Aucune table trouvée.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Éditeur SQL et résultats */}
|
||||
<div className="lg:col-span-3 space-y-8">
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle>Éditeur SQL</CardTitle>
|
||||
<CardDescription>
|
||||
Entrez votre requête SQL ci-dessous
|
||||
</CardDescription>
|
||||
<div className="space-y-6 lg:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Requête</CardTitle>
|
||||
<CardDescription>Ctrl + Entrée pour exécuter</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="SELECT * FROM users LIMIT 10;"
|
||||
value={sqlQuery}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -155,7 +137,7 @@ export default function SqlAdmin() {
|
||||
disabled={executeSqlMutation.isPending}
|
||||
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"}
|
||||
</Button>
|
||||
|
||||
@@ -172,13 +154,17 @@ export default function SqlAdmin() {
|
||||
|
||||
{/* Résultats */}
|
||||
{queryResult && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Résultats</CardTitle>
|
||||
<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>
|
||||
</div>
|
||||
{queryResult.success && queryResult.result && (
|
||||
@@ -188,7 +174,7 @@ export default function SqlAdmin() {
|
||||
onClick={handleDownloadResult}
|
||||
data-testid="button-download-result"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="h-4 w-4" />
|
||||
Télécharger JSON
|
||||
</Button>
|
||||
)}
|
||||
@@ -196,7 +182,7 @@ export default function SqlAdmin() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -225,7 +211,7 @@ export default function SqlAdmin() {
|
||||
</TableBody>
|
||||
</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>
|
||||
</pre>
|
||||
)}
|
||||
@@ -241,8 +227,6 @@ export default function SqlAdmin() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+150
-190
@@ -1,20 +1,22 @@
|
||||
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 Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card } 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 { 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 { 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";
|
||||
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon, KeyRound } from "lucide-react";
|
||||
|
||||
type UserData = {
|
||||
id: string;
|
||||
@@ -36,13 +38,13 @@ type UserPagePermission = {
|
||||
};
|
||||
|
||||
export default function UsersAdmin() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Form states
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<"admin" | "user">("user");
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
// Edit dialog states
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
@@ -88,6 +90,7 @@ export default function UsersAdmin() {
|
||||
description: `L'utilisateur ${data.username} a été créé avec succès`,
|
||||
});
|
||||
setUsername("");
|
||||
setCreateDialogOpen(false);
|
||||
setPassword("");
|
||||
setRole("user");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
@@ -278,190 +281,144 @@ export default function UsersAdmin() {
|
||||
};
|
||||
|
||||
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)}
|
||||
<>
|
||||
<Page width="default">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
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>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton h-16 rounded-xl" />
|
||||
))}
|
||||
</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 ? (
|
||||
<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">Chargement...</p>
|
||||
</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom d'utilisateur</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium flex items-center gap-2">
|
||||
{user.role === "admin" ? (
|
||||
<Shield className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<UserIcon className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
{user.username}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{user.role === "user" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionsClick(user)}
|
||||
data-testid={`button-permissions-user-${user.id}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditClick(user)}
|
||||
data-testid={`button-edit-user-${user.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
data-testid={`button-delete-user-${user.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">Aucun utilisateur trouvé</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création */}
|
||||
<div>
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
Créer un utilisateur
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ajouter un nouvel utilisateur
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="utilisateur123"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
data-testid="input-new-username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
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">Rôle</Label>
|
||||
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
|
||||
<SelectTrigger id="role" data-testid="select-user-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
) : users && users.length > 0 ? (
|
||||
<Card className="overflow-hidden">
|
||||
<ul className="divide-y">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="flex items-center gap-3 p-4" data-testid={`row-user-${user.id}`}>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className={user.role === "admin" ? "bg-primary/10 font-semibold text-primary" : "bg-muted font-semibold text-muted-foreground"}>
|
||||
{user.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{user.username}</p>
|
||||
<Badge variant={user.role === "admin" ? "default" : "muted"} className="mt-1 gap-1">
|
||||
{user.role === "admin" ? <Shield className="h-3 w-3" /> : <UserIcon className="h-3 w-3" />}
|
||||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{user.role === "user" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionsClick(user)}
|
||||
data-testid={`button-permissions-user-${user.id}`}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Accès aux pages</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createUserMutation.isPending}
|
||||
data-testid="button-create-user"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => handleEditClick(user)}
|
||||
aria-label="Modifier"
|
||||
data-testid={`button-edit-user-${user.id}`}
|
||||
>
|
||||
{createUserMutation.isPending ? "Création..." : "Créer"}
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
aria-label="Supprimer"
|
||||
data-testid={`button-delete-user-${user.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Aucun utilisateur"
|
||||
action={<Button onClick={() => setCreateDialogOpen(true)}>Créer un utilisateur</Button>}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
|
||||
{/* Dialog de création */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvel utilisateur</DialogTitle>
|
||||
<DialogDescription>Il pourra se connecter immédiatement avec ces identifiants.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="utilisateur123"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
data-testid="input-new-username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
data-testid="input-new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">4 caractères minimum</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Rôle</Label>
|
||||
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
|
||||
<SelectTrigger id="role" data-testid="select-user-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur — publie sur les pages autorisées</SelectItem>
|
||||
<SelectItem value="admin">Administrateur — accès complet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createUserMutation.isPending} data-testid="button-create-user">
|
||||
{createUserMutation.isPending ? "Création…" : "Créer l'utilisateur"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de modification */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
@@ -525,7 +482,7 @@ export default function UsersAdmin() {
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogTitle>Supprimer cet utilisateur ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
|
||||
Cette action est irréversible.
|
||||
@@ -548,7 +505,7 @@ export default function UsersAdmin() {
|
||||
<Dialog open={permissionsDialogOpen} onOpenChange={setPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gérer les permissions</DialogTitle>
|
||||
<DialogTitle>Accès aux pages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez les pages auxquelles l'utilisateur <strong>{permissionsUser?.username}</strong> peut accéder
|
||||
</DialogDescription>
|
||||
@@ -557,7 +514,7 @@ export default function UsersAdmin() {
|
||||
{allPages && allPages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{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
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
@@ -568,9 +525,12 @@ export default function UsersAdmin() {
|
||||
htmlFor={`page-${page.id}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="font-medium">{page.pageName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{page.platform === "facebook" ? "Facebook" : "Instagram"}
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformIcon platform={page.platform} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium">{page.pageName}</div>
|
||||
<div className="text-xs text-muted-foreground">{platformLabel(page.platform)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -596,6 +556,6 @@ export default function UsersAdmin() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
totalDuration: number;
|
||||
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
audioUrl?: string;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
|
||||
showWatermark?: boolean;
|
||||
storeName?: string;
|
||||
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
|
||||
endingSeconds?: 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>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
|
||||
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
|
||||
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
|
||||
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
|
||||
|
||||
/** La durée vient des propriétés : chaque Reel a la sienne. */
|
||||
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ReelVideoProps}
|
||||
/>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+31
-143
@@ -1,155 +1,43 @@
|
||||
# Social Flow - Design Guidelines
|
||||
|
||||
## Design Approach
|
||||
Interface sobre et lisible, en français, pensée d'abord pour être comprise sans formation.
|
||||
Toutes les couleurs passent par les tokens de `client/src/index.css` (modes clair et sombre).
|
||||
|
||||
**Selected Framework:** Design System Approach inspired by modern SaaS dashboards (Linear, Buffer, Notion) with Material Design principles for data-rich interfaces.
|
||||
## Identité
|
||||
|
||||
**Core Philosophy:** Professional productivity tool prioritizing clarity, efficiency, and visual hierarchy in dark mode with sophisticated blue/purple branding.
|
||||
- **Logo** : `components/brand/logo.tsx` (`<Logo />`, `<LogoMark />`) — deux ondes qui s'écoulent vers un point, sur un carré arrondi en dégradé indigo → corail. Même dessin dans `client/public/favicon.svg`.
|
||||
- **Primaire** : indigo `#4F46E5` (`primary`) — actions, éléments actifs, focus.
|
||||
- **Accent** : corail `#F97362` (`brand-accent`) — uniquement dans le dégradé de marque.
|
||||
- **Dégradé de marque** (`.gradient-brand`, bouton `variant="brand"`) : réservé au logo et à **une seule** action principale par écran (publier, créer, se connecter).
|
||||
- **Statuts** : `success` (publié, actif), `warning` (bientôt expiré), `destructive` (échec, suppression), `info` (en cours).
|
||||
- **Plateformes** : `facebook`, `instagram`, `tiktok` — via `components/platform-icon.tsx`.
|
||||
- Ne jamais utiliser de couleurs Tailwind brutes (`bg-blue-500`, `#1877F2`…) ni `text-white` sur une surface de l'interface.
|
||||
|
||||
## Color Palette
|
||||
## Typographie
|
||||
|
||||
**Dark Mode (Primary Theme):**
|
||||
- Background Base: 222 20% 8%
|
||||
- Surface Elevated: 222 18% 12%
|
||||
- Surface Card: 222 16% 15%
|
||||
- Border Subtle: 222 12% 20%
|
||||
- Inter (400–700), JetBrains Mono pour les identifiants et le SQL.
|
||||
- Titre de page : `PageHeader` (`text-2xl sm:text-[28px] font-semibold`).
|
||||
- Titre de carte : `CardTitle` (`text-base font-semibold`), description en `text-muted-foreground`.
|
||||
|
||||
**Brand Colors:**
|
||||
- Primary Blue: 215 85% 58%
|
||||
- Primary Purple: 265 75% 62%
|
||||
- Gradient Accent: Linear gradient from Primary Blue to Primary Purple
|
||||
## Mise en page
|
||||
|
||||
**Functional Colors:**
|
||||
- Success Green: 142 76% 48%
|
||||
- Warning Orange: 35 95% 58%
|
||||
- Error Red: 0 85% 62%
|
||||
- Info Cyan: 195 85% 55%
|
||||
- **Shell** : `components/layout/app-shell.tsx` — sidebar groupée sur desktop (≥ lg), en-tête + barre de navigation basse sur mobile (Accueil, Calendrier, **Créer**, Médias, Menu). La navigation est définie une seule fois dans `components/layout/nav-config.ts`.
|
||||
- **Contenu** : chaque page commence par `<Page width="narrow|default|wide">` puis `<PageHeader icon title description actions />`.
|
||||
- `narrow` (max-w-3xl) : formulaires simples
|
||||
- `default` (max-w-6xl) : la plupart des pages
|
||||
- `wide` (1400px) : tableau de bord, calendrier, médiathèque
|
||||
- Une seule version responsive par écran : pas de pages mobiles séparées.
|
||||
|
||||
**Text Hierarchy:**
|
||||
- Primary Text: 0 0% 95%
|
||||
- Secondary Text: 222 10% 65%
|
||||
- Tertiary Text: 222 8% 48%
|
||||
## Composants
|
||||
|
||||
## Typography
|
||||
- **Cartes** : `Card` sans surcharge de style (`rounded-xl border shadow-soft`).
|
||||
- **États vides** : `components/empty-state.tsx` avec une action suggérée.
|
||||
- **Parcours de création** : `components/stepper.tsx` (`Stepper` + `StepNavigation`), utilisé par Publication et Reel. On explique toujours pourquoi une étape est verrouillée.
|
||||
- **Choix exclusifs** : cartes sélectionnables (`RadioGroup` dans un `label` bordé), pas de listes déroulantes quand il y a 2 à 4 options.
|
||||
- **Suppressions** : toujours confirmées par `AlertDialog`, jamais `confirm()`.
|
||||
- **Badges** : variantes `success`, `warning`, `info`, `danger`, `muted`.
|
||||
|
||||
**Font Family:**
|
||||
- Primary: 'Inter' from Google Fonts (400, 500, 600, 700)
|
||||
- Monospace: 'JetBrains Mono' for timestamps and metrics
|
||||
## Rédaction
|
||||
|
||||
**Scale:**
|
||||
- Headings H1: text-3xl font-semibold
|
||||
- Headings H2: text-2xl font-semibold
|
||||
- Headings H3: text-xl font-medium
|
||||
- Body Large: text-base
|
||||
- Body: text-sm
|
||||
- Captions: text-xs text-secondary
|
||||
|
||||
## Layout System
|
||||
|
||||
**Spacing Primitives:** Tailwind units of 2, 4, 6, 8, 12, 16 (e.g., p-4, gap-6, mt-8)
|
||||
|
||||
**Grid Structure:**
|
||||
- Sidebar: Fixed width 280px (w-70) on desktop, collapsible on mobile
|
||||
- Main Content: Flexible with max-w-7xl container
|
||||
- Dashboard Grid: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 with gap-6
|
||||
|
||||
**Container Widths:**
|
||||
- Dashboard sections: max-w-7xl with px-6 to px-8
|
||||
- Form containers: max-w-2xl
|
||||
- Content cards: Full width within grid columns
|
||||
|
||||
## Component Library
|
||||
|
||||
### Sidebar Navigation
|
||||
- Dark surface (Background Base) with subtle border-right
|
||||
- Logo + app name at top (h-16)
|
||||
- Navigation items with icons (Heroicons) + labels
|
||||
- Active state: Primary gradient background with 12% opacity, blue text
|
||||
- Hover: Surface Elevated background
|
||||
- Bottom section: User profile card with avatar, name, settings icon
|
||||
- Spacing: py-2 between items, px-4 horizontal padding
|
||||
|
||||
### Dashboard Cards
|
||||
- Background: Surface Card with rounded-xl
|
||||
- Border: 1px solid Border Subtle
|
||||
- Shadow: Soft shadow (shadow-lg with low opacity)
|
||||
- Padding: p-6
|
||||
- Header: Flex justify-between with title (text-lg font-semibold) + action button
|
||||
- Content spacing: space-y-4 internally
|
||||
|
||||
### Post Scheduling Interface
|
||||
- Calendar view: Week/month grid with date cells
|
||||
- Post cards: Compact cards showing thumbnail + caption preview + platform badges (FB/IG)
|
||||
- Drag-and-drop visual indicators
|
||||
- Time slots: Hourly breakdown with scheduled posts
|
||||
|
||||
### Analytics Cards
|
||||
- Metric cards: Large number (text-3xl font-bold) + label + trend indicator
|
||||
- Charts: Use Chart.js with gradient fills matching brand colors
|
||||
- Grid layout for KPIs: 4 columns on desktop, responsive down
|
||||
|
||||
### Forms & Inputs
|
||||
- Input fields: Surface Elevated background, border Border Subtle, rounded-lg, px-4 py-3
|
||||
- Focus state: Primary Blue border, subtle glow
|
||||
- Labels: text-sm font-medium mb-2, Secondary Text color
|
||||
- Textarea: min-h-32 for post composition
|
||||
- File upload: Dashed border drop zone with center icon and text
|
||||
|
||||
### Buttons
|
||||
- Primary: Gradient background (blue to purple), text-white, rounded-lg, px-6 py-3, font-medium
|
||||
- Secondary: Border Primary Blue, text Primary Blue, transparent background
|
||||
- Ghost: Transparent, hover Surface Elevated
|
||||
- Icon buttons: w-10 h-10, rounded-lg, center content
|
||||
|
||||
### Platform Badges
|
||||
- Small pills (px-3 py-1, rounded-full, text-xs font-medium)
|
||||
- Facebook: Blue background (215 85% 58% with 15% opacity), blue text
|
||||
- Instagram: Purple background (265 75% 62% with 15% opacity), purple text
|
||||
|
||||
### Status Indicators
|
||||
- Dots with colors: w-2 h-2 rounded-full
|
||||
- Scheduled: Primary Blue
|
||||
- Published: Success Green
|
||||
- Failed: Error Red
|
||||
- Draft: Tertiary Text
|
||||
|
||||
### Data Tables
|
||||
- Header row: Surface Elevated background, font-medium, uppercase text-xs
|
||||
- Rows: Border-bottom subtle, hover Surface Card
|
||||
- Padding: px-6 py-4
|
||||
- Alternating backgrounds for better readability
|
||||
|
||||
## Animations
|
||||
|
||||
**Minimal & Purposeful:**
|
||||
- Page transitions: Fade-in only (150ms)
|
||||
- Card hover: Subtle lift (translateY -2px, 200ms)
|
||||
- Button interactions: Scale 0.98 on active
|
||||
- Navigation: Smooth color transitions (200ms)
|
||||
- No distracting scroll animations or complex effects
|
||||
|
||||
## Images
|
||||
|
||||
**Hero Section:** None - Dashboard-first interface prioritizing immediate utility
|
||||
|
||||
**Avatar & Thumbnails:**
|
||||
- User avatars: w-10 h-10, rounded-full with border
|
||||
- Post thumbnails: aspect-video, rounded-lg, object-cover
|
||||
- Platform logos: Use official brand SVGs at 20x20 or 24x24
|
||||
|
||||
**Empty States:**
|
||||
- Illustration placeholders for "No posts scheduled", "No analytics data"
|
||||
- Center aligned with icon, heading, and CTA button
|
||||
- Subtle grayscale illustrations maintaining professional tone
|
||||
|
||||
## Key Screens Layout
|
||||
|
||||
**Dashboard:** 3-column grid with Overview metrics (top row), Recent Posts card, Upcoming Schedule card, Quick Analytics
|
||||
|
||||
**Calendrier:** Full-width calendar component, left sidebar filter (platforms, status), right panel for selected post details
|
||||
|
||||
**Nouvelle Publication:** Center form (max-w-2xl), left preview panel showing platform-specific mockups, sticky publish/schedule buttons
|
||||
|
||||
**Analytiques:** Metric cards grid (4 cols), followed by full-width charts for engagement over time, best posting times heatmap
|
||||
|
||||
**Paramètres:** Two-column layout, left navigation tabs, right content panels
|
||||
- Verbes d'action clairs : « Importer », « Programmer », « Connecter un compte ».
|
||||
- Pas d'anglicismes (« Uploader », « Token ») ni de jargon technique visible par défaut : les détails vont dans un bloc « Besoin d'aide ? » repliable.
|
||||
+61
-3
@@ -55,8 +55,8 @@ services:
|
||||
# Rendu des Reels vidéo : remotion (sous-titres animés, identique à
|
||||
# l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques)
|
||||
REEL_RENDERER: ${REEL_RENDERER:-remotion}
|
||||
# Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs)
|
||||
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
|
||||
# Service de rendu Remotion (conteneur Node dédié, même clé que FFmpeg)
|
||||
REMOTION_RENDERER_URL: http://remotion-renderer:8100
|
||||
networks:
|
||||
- internal
|
||||
- nginx_default
|
||||
@@ -69,7 +69,27 @@ services:
|
||||
- media_uploads:/app/uploads/media
|
||||
- logos_uploads:/app/uploads/logos
|
||||
- stories_uploads:/app/uploads/stories
|
||||
command: npm run start
|
||||
|
||||
# Rendu Remotion des Reels (sous-titres animés, logo, effet de fin)
|
||||
remotion-renderer:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: remotion-service/Dockerfile
|
||||
image: socialflow-remotion-renderer:latest
|
||||
pull_policy: build
|
||||
container_name: socialflow-remotion
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
PORT: 8100
|
||||
# Onglets Chromium par rendu (défaut : moitié des cœurs)
|
||||
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
|
||||
MAX_PARALLEL_RENDERS: 1
|
||||
shm_size: 1gb
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8100"
|
||||
|
||||
# Service FFmpeg dédié
|
||||
ffmpeg-api:
|
||||
@@ -86,11 +106,49 @@ services:
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||
# Service Qwen TTS par défaut. Normalement réglé dans l'application
|
||||
# (Paramètres → Qwen TTS, ex. serveur GPU Unraid) ; pour le service local
|
||||
# ci-dessous : QWEN_TTS_URL=http://qwen-tts:8001
|
||||
QWEN_TTS_URL: ${QWEN_TTS_URL:-}
|
||||
QWEN_TTS_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
# Voix Qwen3-TTS sur ce serveur, en CPU (optionnel, lent : 5 à 7 fois la
|
||||
# durée de la voix sur 4 cœurs). Sur un serveur GPU, préférez
|
||||
# qwen-tts/docker-compose.gpu.yml. Démarrage : docker compose --profile qwen-local up -d
|
||||
qwen-tts:
|
||||
profiles: ["qwen-local"]
|
||||
build:
|
||||
context: ./qwen-tts
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# 1.7B : ton et émotion pilotés par consigne ; 0.6B : ~30 % plus rapide
|
||||
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||
# Clonage des voix déposées dans qwen-tts/voices (vide : désactivé)
|
||||
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-0.6B-Base}
|
||||
image: socialflow-qwen-tts:latest
|
||||
pull_policy: build
|
||||
container_name: socialflow-qwen-tts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
# Threads de calcul (0 : tous les cœurs)
|
||||
QWEN_TTS_THREADS: ${QWEN_TTS_THREADS:-0}
|
||||
volumes:
|
||||
# Voix clonées ajoutables sans reconstruire l'image
|
||||
- ./qwen-tts/voices:/app/voices:ro
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8001"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${QWEN_TTS_MEMORY:-10g}
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL, ensure the database is provisioned");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migrations",
|
||||
schema: "./shared/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
@@ -116,12 +116,22 @@ def _model():
|
||||
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
|
||||
|
||||
|
||||
# Silence ajouté avant la voix pour Whisper : sur une voix qui démarre
|
||||
# aussitôt, il laissait souvent passer le premier mot (« Alerte »).
|
||||
LEAD_SILENCE = 0.5
|
||||
|
||||
|
||||
def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
import numpy as np
|
||||
from faster_whisper.audio import decode_audio
|
||||
|
||||
samples = decode_audio(str(audio), sampling_rate=16000)
|
||||
padded = np.concatenate([np.zeros(int(16000 * LEAD_SILENCE), dtype=samples.dtype), samples])
|
||||
# Pas de texte attendu en amorce (initial_prompt) ni d'enchaînement sur le
|
||||
# segment précédent : les deux font halluciner Whisper, qui répète la phrase
|
||||
# et masque une fin tronquée. On veut entendre ce qui est réellement dit.
|
||||
segments, _ = _model().transcribe(
|
||||
str(audio),
|
||||
padded,
|
||||
language="fr",
|
||||
word_timestamps=True,
|
||||
condition_on_previous_text=False,
|
||||
@@ -129,7 +139,7 @@ def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
beam_size=5,
|
||||
)
|
||||
return [
|
||||
Word(w.word.strip(), float(w.start), float(w.end))
|
||||
Word(w.word.strip(), max(0.0, w.start - LEAD_SILENCE), max(0.0, w.end - LEAD_SILENCE))
|
||||
for segment in segments
|
||||
for w in (segment.words or [])
|
||||
if w.word.strip()
|
||||
|
||||
@@ -56,6 +56,9 @@ class TtsRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
# Service Qwen TTS réglé dans les paramètres (sinon : variables d'environnement)
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
|
||||
|
||||
class SrtCue(BaseModel):
|
||||
@@ -80,6 +83,8 @@ class ReelRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
draw_text: bool = True
|
||||
stabilize: bool = False
|
||||
enable_ending_effect: bool = True
|
||||
@@ -99,8 +104,14 @@ async def health():
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def list_voices():
|
||||
return voices.catalog()
|
||||
async def list_voices(x_qwen_url: str | None = Header(None), x_qwen_key: str | None = Header(None)):
|
||||
"""Voix par moteur ; l'adresse du service Qwen réglée dans l'application
|
||||
arrive en en-têtes (une clé n'a rien à faire dans une URL)."""
|
||||
qwen_status = await tts.qwen.status(tts.qwen.Target.resolve(x_qwen_url, x_qwen_key))
|
||||
return {
|
||||
**voices.catalog(qwen_status["voices"] if qwen_status else None),
|
||||
"qwen_status": {k: v for k, v in (qwen_status or {}).items() if k != "voices"} or None,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/preview-tts", dependencies=[Depends(require_key)])
|
||||
@@ -187,6 +198,7 @@ async def _synthesize(text: str, display_source: str | None, request, workdir: P
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix impossible à générer")
|
||||
@@ -212,6 +224,7 @@ async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) ->
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix SRT impossible à générer")
|
||||
|
||||
@@ -36,3 +36,11 @@ FPS = 30
|
||||
VOICE_DELAY = 2.0
|
||||
|
||||
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
||||
|
||||
# Service Qwen3-TTS par défaut (service qwen-tts, local ou serveur GPU) ; l'adresse
|
||||
# réglée dans les paramètres de l'application est prioritaire. Vide : aucun.
|
||||
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/")
|
||||
# Sur CPU, la génération prend plusieurs fois la durée de la voix
|
||||
QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
||||
QWEN_TTS_ATTEMPTS = int(os.environ.get("QWEN_TTS_ATTEMPTS", "3"))
|
||||
QWEN_TTS_API_KEY = os.environ.get("QWEN_TTS_API_KEY", "")
|
||||
@@ -9,6 +9,7 @@ pas rogner une syllabe.
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -49,8 +50,8 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
|
||||
« week -end » découpé) ne comptent pas comme des mots manquants, alors
|
||||
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
|
||||
expected = "".join(normalize(w) for w in expected_text.split())
|
||||
heard = "".join(normalize(w.text) for w in spoken)
|
||||
expected = "".join(comparable_tokens(expected_text.split()))
|
||||
heard = "".join(comparable_tokens([w.text for w in spoken]))
|
||||
if not expected:
|
||||
return ReadingCheck(1.0, 0.0, True)
|
||||
if not heard:
|
||||
@@ -70,13 +71,72 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
)
|
||||
|
||||
|
||||
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
|
||||
UNITS = {
|
||||
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
|
||||
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
|
||||
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
|
||||
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
|
||||
"pourcent", "pourcents", "x", "fois",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
# Nombres écrits en lettres : Whisper les écrit en chiffres (« trente » → « 30 »).
|
||||
# « un »/« une » n'y sont pas : ce sont le plus souvent des articles.
|
||||
NUMBER_WORDS = {
|
||||
"zero", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix",
|
||||
"onze", "douze", "treize", "quatorze", "quinze", "seize", "vingt", "vingts",
|
||||
"trente", "quarante", "cinquante", "soixante", "septante", "octante", "nonante",
|
||||
"cent", "cents", "mille", "million", "millions", "milliard", "milliards",
|
||||
} # fmt: skip
|
||||
# Signe avant un nombre : « +30 » est lu « plus trente »
|
||||
SIGNS = {"plus", "moins"}
|
||||
|
||||
|
||||
# Nombres composés, écrits avec traits d'union puis normalisés (« trentecinq »)
|
||||
_COMPOUND_NUMBER = re.compile(
|
||||
"^(?:" + "|".join(sorted(NUMBER_WORDS | {"et", "un", "une"}, key=len, reverse=True)) + ")+$"
|
||||
)
|
||||
|
||||
|
||||
def _is_number(token: str) -> bool:
|
||||
if any(c.isdigit() for c in token) or token in NUMBER_WORDS:
|
||||
return True
|
||||
return token not in {"un", "une", "et"} and bool(_COMPOUND_NUMBER.match(token))
|
||||
|
||||
|
||||
def comparable_tokens(words: list[str]) -> list[str]:
|
||||
"""Mots normalisés, sans les nombres (en chiffres ou en lettres), leur signe
|
||||
ni l'unité qui les suit : Whisper écrit un prix ou une mesure à sa façon
|
||||
(« 11 € 99 », « 11,99 euros », « 40 cm », « 30 » pour « trente »), ce qui
|
||||
passait pour des mots sautés et relançait la génération."""
|
||||
normalized = [t for t in (normalize(w) for w in words) if t]
|
||||
tokens: list[str] = []
|
||||
after_number = False
|
||||
for index, token in enumerate(normalized):
|
||||
if _is_number(token):
|
||||
after_number = True
|
||||
continue
|
||||
following = normalized[index + 1] if index + 1 < len(normalized) else ""
|
||||
if token in SIGNS and _is_number(following):
|
||||
continue
|
||||
if after_number and token == "pour" and following in {"cent", "cents"}:
|
||||
continue # « trente pour cent » ↔ « 30 % »
|
||||
if after_number and token in UNITS:
|
||||
continue
|
||||
after_number = False
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
|
||||
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
|
||||
heard = {normalize(w.text) for w in spoken}
|
||||
heard = set(comparable_tokens([w.text for w in spoken]))
|
||||
kept = set(comparable_tokens(expected_text.split()))
|
||||
missing = []
|
||||
for word in expected_text.split():
|
||||
token = normalize(word)
|
||||
if token and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
missing.append(word.strip(".,!?;:…"))
|
||||
return missing
|
||||
|
||||
@@ -90,6 +150,10 @@ ACTIVE_RANGE_DB = 38.0
|
||||
# Marges gardées autour de la parole
|
||||
LEAD_PAD = 0.08
|
||||
TAIL_PAD = 0.30
|
||||
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
|
||||
# de la borne estimée par Whisper
|
||||
MIN_PAUSE = 0.08
|
||||
PAUSE_SEARCH = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -139,6 +203,19 @@ class Envelope:
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
|
||||
|
||||
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
|
||||
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
|
||||
quiet = self.db <= self.threshold
|
||||
runs, run_start = [], None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if (i - run_start) * FRAME >= min_length:
|
||||
runs.append((run_start * FRAME, i * FRAME))
|
||||
run_start = None
|
||||
return runs
|
||||
|
||||
def speech_span(self, start: float, end: float) -> tuple[float, float]:
|
||||
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
|
||||
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
|
||||
@@ -170,9 +247,26 @@ async def load_envelope(path: Path) -> Envelope:
|
||||
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
|
||||
|
||||
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float) -> float:
|
||||
"""Coupe entre deux phrases, au point le plus calme de la pause. Les bornes
|
||||
Whisper sont imprécises : on cherche un peu autour."""
|
||||
low = max(0.0, min(previous_end, next_start) - 0.1)
|
||||
high = max(previous_end, next_start) + 0.2
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
|
||||
"""Coupe entre deux phrases, au cœur d'une vraie pause.
|
||||
|
||||
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
|
||||
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
|
||||
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
|
||||
la pause mesurée la plus proche de la borne estimée, les longues pauses
|
||||
(fin de phrase) étant préférées aux courtes (virgule)."""
|
||||
low = max(after, min(previous_end, next_start) - 0.1)
|
||||
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
|
||||
best: tuple[float, float, float] | None = None
|
||||
for start, end in envelope.pauses():
|
||||
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
|
||||
continue # silences avant la première et après la dernière phrase exclus
|
||||
distance = max(0.0, low - end, start - high)
|
||||
if distance > PAUSE_SEARCH:
|
||||
continue
|
||||
score = distance - (end - start)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, max(start, after), end)
|
||||
if best:
|
||||
return envelope.quietest(best[1], best[2])
|
||||
return envelope.quietest(low, high)
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user