mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394db5b63f | ||
|
|
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 |
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
|
||||
@@ -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 !
|
||||
|
||||
@@ -945,42 +945,8 @@ POST /api/analytics/tokens/check
|
||||
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`.
|
||||
|
||||
Les requêtes JSON sous `/api/v1` peuvent peser jusqu'à 15 Mo (contre 100 Ko ailleurs), pour transporter une image en base64.
|
||||
|
||||
### Envoyer une image dans la médiathèque
|
||||
Envoie une image directement, sans hébergeur public intermédiaire. L'`id` renvoyé se passe en `mediaId` à `/publish` ou à `PATCH /posts/:id`.
|
||||
Formats acceptés : JPEG, PNG, WebP, GIF (reconnus par leur contenu, pas par l'extension) — 10 Mo maximum.
|
||||
```http
|
||||
POST /api/v1/media
|
||||
```
|
||||
**Body (multipart/form-data) :** champ `file` contenant l'image.
|
||||
```bash
|
||||
curl -X POST https://socialflow.example/api/v1/media \
|
||||
-H "X-API-Key: $SOCIALFLOW_API_KEY" \
|
||||
-F "file=@galette-chaise.jpg"
|
||||
```
|
||||
**Ou body (JSON) :** l'image en base64, brute ou en data URL.
|
||||
```json
|
||||
{ "imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }
|
||||
```
|
||||
**Réponse (201 Created) :**
|
||||
```json
|
||||
{
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg",
|
||||
"type": "image",
|
||||
"fileName": "external-1791141548234.jpg",
|
||||
"fileSize": 2483120
|
||||
}
|
||||
```
|
||||
**Erreurs :** `400` (aucune image, format non reconnu), `401` (clé absente ou invalide), `413` (image de plus de 10 Mo).
|
||||
|
||||
### Publier un message avec image
|
||||
Crée une publication et programme sa publication. L'image peut être fournie de quatre façons (une seule par requête) :
|
||||
- `mediaId` : image déjà envoyée via `POST /api/v1/media` ;
|
||||
- `imageData` : image en base64 (ou data URL) ;
|
||||
- `imageUrl` : URL publique que Socialflow télécharge lui-même ;
|
||||
- en multipart/form-data, un fichier dans le champ `image` (les autres champs en texte, `pageIds` en tableau JSON ou séparés par des virgules).
|
||||
### Publier un message avec téléchargement d'image
|
||||
Crée une publication en téléchargeant automatiquement l'image depuis une URL publique fournie (ex: depuis votre ERP ou flux e-commerce), et programme sa publication.
|
||||
```http
|
||||
POST /api/v1/publish
|
||||
```
|
||||
@@ -988,7 +954,7 @@ POST /api/v1/publish
|
||||
```json
|
||||
{
|
||||
"content": "Découvrez cet article exceptionnel !",
|
||||
"mediaId": "med_ext_123",
|
||||
"imageUrl": "https://mon-site.com/images/produit.jpg",
|
||||
"pageIds": ["page_999"],
|
||||
"scheduledAt": "2026-06-02T08:00:00.000Z",
|
||||
"postType": "feed"
|
||||
@@ -1008,8 +974,8 @@ POST /api/v1/publish
|
||||
{ "pageId": "page_999", "pageName": "Ma Page", "scheduledPostId": "sch_ext_001" }
|
||||
],
|
||||
"media": {
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg"
|
||||
"id": "med_ext_999",
|
||||
"url": "/uploads/media/external-17169.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1052,7 +1018,7 @@ PATCH /api/v1/posts/:id
|
||||
"imageUrl": "https://mon-site.com/images/produit-rectifie.jpg"
|
||||
}
|
||||
```
|
||||
*Note : Tous les champs sont facultatifs. Une nouvelle image (`imageUrl`, `imageData`, `mediaId` ou fichier multipart `image`) remplace le média précédent.*
|
||||
*Note : Tous les champs sont facultatifs. L'envoi d'une nouvelle URL d'image écrase et remplace le média précédent.*
|
||||
|
||||
**Réponse (200 OK) :**
|
||||
*(Retourne la publication modifiée avec sa liste de planification actualisée).*
|
||||
|
||||
+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 |
+75
-119
@@ -1,99 +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
|
||||
);
|
||||
|
||||
// Reel déjà monté : même page responsive sur mobile et desktop
|
||||
const ScheduleReel = createResponsiveRoute(
|
||||
() => import("@/pages/schedule-reel"),
|
||||
() => import("@/pages/schedule-reel")
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -101,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() {
|
||||
@@ -149,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>
|
||||
);
|
||||
@@ -157,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>
|
||||
|
||||
@@ -23,12 +23,7 @@ function getStageLabel(post: OngoingReel): string {
|
||||
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
|
||||
}
|
||||
|
||||
interface OngoingReelsProps {
|
||||
/** Compact layout for mobile */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
export default function OngoingReels() {
|
||||
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||
queryKey: ["/api/reels/ongoing"],
|
||||
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
||||
@@ -65,13 +60,9 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : ""
|
||||
}`}
|
||||
data-testid="card-ongoing-reels"
|
||||
>
|
||||
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||
<Card data-testid="card-ongoing-reels">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
@@ -80,8 +71,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
Reels en cours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||
<div className={`space-y-${compact ? "3" : "4"}`}>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{ongoingPosts.map((post) => {
|
||||
const progress = post.generationProgress ?? 0;
|
||||
const isFailed = post.generationStatus === "failed";
|
||||
@@ -89,17 +80,17 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`flex items-start gap-3 p-${compact ? "3" : "4"} rounded-lg border border-border/50 bg-muted/30`}
|
||||
className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3"
|
||||
data-testid={`ongoing-reel-${post.id}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4 text-white" />
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Clapperboard className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${compact ? "text-sm" : "text-sm"}`}>
|
||||
<p className="font-medium truncate text-sm">
|
||||
{post.content || "Reel sans texte"}
|
||||
</p>
|
||||
|
||||
@@ -130,7 +121,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
)}
|
||||
|
||||
{progress >= 100 && !isFailed && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-green-500">
|
||||
<div className="flex items-center gap-1.5 mt-2 text-success">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Terminé</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Platform = "facebook" | "instagram" | "tiktok" | string;
|
||||
|
||||
const META: Record<string, { label: string; icon: typeof SiFacebook; text: string; bg: string }> = {
|
||||
facebook: { label: "Facebook", icon: SiFacebook, text: "text-facebook", bg: "bg-facebook/10" },
|
||||
instagram: { label: "Instagram", icon: SiInstagram, text: "text-instagram", bg: "bg-instagram/10" },
|
||||
tiktok: { label: "TikTok", icon: SiTiktok, text: "text-tiktok", bg: "bg-tiktok/10" },
|
||||
};
|
||||
|
||||
export function platformLabel(platform: Platform): string {
|
||||
return META[platform]?.label ?? platform;
|
||||
}
|
||||
|
||||
/** Icône de plateforme sur pastille teintée (couleurs officielles via tokens). */
|
||||
export function PlatformIcon({
|
||||
platform,
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
platform: Platform;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = META[platform] ?? META.facebook;
|
||||
const Icon = meta.icon;
|
||||
const box = { sm: "h-7 w-7 rounded-md", md: "h-10 w-10 rounded-lg", lg: "h-12 w-12 rounded-xl" }[size];
|
||||
const icon = { sm: "h-3.5 w-3.5", md: "h-5 w-5", lg: "h-6 w-6" }[size];
|
||||
return (
|
||||
<span className={cn("inline-flex shrink-0 items-center justify-center", box, meta.bg, meta.text, className)}>
|
||||
<Icon className={icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
|
||||
@@ -80,8 +80,8 @@ export function QwenSettingsCard() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5" />
|
||||
Qwen TTS (voix locale)
|
||||
@@ -113,12 +113,12 @@ export function QwenSettingsCard() {
|
||||
data-testid="input-qwen-api-key"
|
||||
/>
|
||||
{config?.hasApiKey && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">✓ Clé enregistrée (laisser vide pour la garder)</p>
|
||||
<p className="text-xs text-success">✓ Clé enregistrée (laisser vide pour la garder)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{test && (
|
||||
<p className={`text-sm ${test.ok ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
||||
<p className={`text-sm ${test.ok ? "text-success" : "text-destructive"}`}>
|
||||
{test.ok
|
||||
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
|
||||
: `✗ ${test.error}`}
|
||||
@@ -133,7 +133,7 @@ export function QwenSettingsCard() {
|
||||
disabled={runTest.isPending || (!url && !config?.url)}
|
||||
data-testid="button-test-qwen"
|
||||
>
|
||||
{runTest.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{runTest.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -223,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, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronsLeft, ChevronsRight, LogOut, Plus } from "lucide-react";
|
||||
import { Logo, LogoMark } from "@/components/brand/logo";
|
||||
import { ThemeToggle } from "@/components/theme-provider";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isActivePath, visibleGroups, type NavItem } from "@/components/layout/nav-config";
|
||||
import { useLogout, useSession } from "@/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
onLinkClick?: () => void;
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("sidebar-collapsed") === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
function NavLink({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||
const Icon = item.icon;
|
||||
const link = (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 rounded-lg px-3 py-[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: null },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: null },
|
||||
];
|
||||
|
||||
const statsItems = [
|
||||
{ icon: Clock, label: "Historique", href: "/history" },
|
||||
];
|
||||
const groups = visibleGroups(isAdmin);
|
||||
const initials = session?.username.substring(0, 2).toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<aside className={`bg-sidebar border-r border-sidebar-border flex flex-col h-screen transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-72'}`}>
|
||||
{/* Logo & Brand */}
|
||||
<div className="p-6 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 gradient-primary rounded-xl flex items-center justify-center shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold gradient-text">Social Flow</h1>
|
||||
<p className="text-xs text-muted-foreground">Automatisation sociale</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 lg:flex",
|
||||
collapsed ? "w-[76px]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border", collapsed ? "justify-center" : "px-5")}>
|
||||
<Link href="/" aria-label="Accueil Social Flow">
|
||||
{collapsed ? <LogoMark size={34} /> : <Logo size={34} />}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
<div className={cn("pt-4", collapsed ? "px-3" : "px-4")}>
|
||||
<Button asChild variant="brand" className={cn("w-full", collapsed && "px-0")} data-testid="button-sidebar-create">
|
||||
<Link href="/new" aria-label="Créer une publication">
|
||||
<Plus className="h-4 w-4" />
|
||||
{!collapsed && "Créer"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl font-medium transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold gradient-primary text-white shadow-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="mt-8 pt-6 border-t border-sidebar-border">
|
||||
{!isCollapsed && (
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-3 px-4 uppercase tracking-wider">Statistiques</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{statsItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
data-testid={`link-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</a>
|
||||
{isCollapsed && (
|
||||
<div className="absolute left-full ml-2 px-3 py-2 bg-popover text-popover-foreground text-sm rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
<nav className={cn("flex-1 overflow-y-auto py-4 scrollbar-none", collapsed ? "px-3" : "px-4")} aria-label="Navigation principale">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.label ?? gi} className={cn(gi > 0 && "mt-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,
|
||||
CalendarClock
|
||||
} 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: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" },
|
||||
{ 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,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,19 +1,21 @@
|
||||
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, Video } from "lucide-react";
|
||||
import { CalendarClock, Upload, Loader2, Check, Link2 } from "lucide-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
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 { 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 { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
import { PlatformIcon } from "@/components/platform-icon";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
|
||||
@@ -22,7 +24,6 @@ import { DateTimePicker } from "@/components/datetime-picker";
|
||||
* publiée telle quelle par le planificateur à la date choisie.
|
||||
*/
|
||||
export default function ScheduleReel() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -30,6 +31,7 @@ export default function ScheduleReel() {
|
||||
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"] });
|
||||
@@ -83,6 +85,7 @@ export default function ScheduleReel() {
|
||||
mediaIds: [selectedVideo!.id],
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate!.toISOString(),
|
||||
alsoStory,
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
@@ -108,59 +111,56 @@ export default function ScheduleReel() {
|
||||
setSelectedPages((prev) => (checked ? [...prev, id] : prev.filter((p) => p !== id)));
|
||||
};
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`schedule-reel-page-${page.id}`}
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onCheckedChange={(checked) => togglePage(page.id, checked === true)}
|
||||
/>
|
||||
<label htmlFor={`schedule-reel-page-${page.id}`} className="text-sm font-medium leading-none flex-1">
|
||||
{page.pageName}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<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-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<CalendarClock className="w-8 h-8 text-primary" />
|
||||
Programmer un Reel
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Publiez une vidéo déjà montée, telle quelle, à la date et l'heure choisies
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Video className="w-5 h-5" /> Vidéo
|
||||
</CardTitle>
|
||||
<CardDescription>Format vertical 9:16 recommandé</CardDescription>
|
||||
{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 hover:border-primary/50"
|
||||
isDragActive ? "border-primary bg-primary/5" : "border-border bg-muted/30 hover:border-primary/50"
|
||||
}`}
|
||||
data-testid="dropzone-schedule-reel"
|
||||
>
|
||||
@@ -201,7 +201,7 @@ export default function ScheduleReel() {
|
||||
/>
|
||||
{selected && (
|
||||
<div className="absolute inset-0 bg-primary/30 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-white" />
|
||||
<Check className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -222,7 +222,8 @@ export default function ScheduleReel() {
|
||||
{/* 2. Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Description</CardTitle>
|
||||
{sectionTitle(2, "Description", !!description.trim())}
|
||||
<CardDescription>Texte publié avec le Reel (facultatif).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
@@ -238,26 +239,29 @@ export default function ScheduleReel() {
|
||||
{/* 3. Destinations et date */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publication</CardTitle>
|
||||
{sectionTitle(3, "Destinations et date", canSchedule)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ou compte TikTok connecté.</p>
|
||||
<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">
|
||||
<Label className="flex items-center gap-2">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
|
||||
</Label>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
<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">
|
||||
<Label className="flex items-center gap-2">
|
||||
<SiTiktok className="w-4 h-4" /> Comptes TikTok
|
||||
</Label>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -266,6 +270,7 @@ export default function ScheduleReel() {
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir la date et l'heure"
|
||||
/>
|
||||
{isPast && (
|
||||
@@ -276,21 +281,21 @@ export default function ScheduleReel() {
|
||||
<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 mr-2 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CalendarClock className="w-4 h-4 mr-2" />
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
)}
|
||||
Programmer le Reel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+276
-273
@@ -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";
|
||||
@@ -15,8 +16,27 @@ 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("");
|
||||
@@ -258,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);
|
||||
@@ -266,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",
|
||||
});
|
||||
},
|
||||
@@ -418,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
|
||||
@@ -487,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)
|
||||
@@ -511,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
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -568,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
|
||||
@@ -590,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)
|
||||
@@ -628,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"
|
||||
@@ -649,214 +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>
|
||||
)}
|
||||
|
||||
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
||||
{isAdmin && <QwenSettingsCard />}
|
||||
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Image className="w-5 h-5" />
|
||||
Logo de l'entreprise
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Uploadez votre logo pour l'ajouter aux images éditées
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{logoPreview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="relative w-full max-w-xs mx-auto">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo"
|
||||
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
data-testid="button-delete-logo"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
data-testid="button-replace-logo"
|
||||
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload-replace"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
||||
</p>
|
||||
<Label htmlFor="logo-upload" className="cursor-pointer">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
||||
data-testid="button-upload-logo"
|
||||
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleLogoChange(e);
|
||||
if (e.target.files?.[0]) {
|
||||
uploadLogoMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!cloudinaryConfig && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
||||
Veuillez d'abord configurer MinIO ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé API configurée et active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKey">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKey"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Facebook className="w-5 h-5" />
|
||||
Facebook
|
||||
@@ -869,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>
|
||||
@@ -929,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>
|
||||
@@ -992,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+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.
|
||||
+23
-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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
-- Script d'initialisation de la base de données Social Flow
|
||||
-- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut
|
||||
|
||||
-- IMPORTANT: Pour les serveurs privés existants, ajouter la valeur 'both' à l'enum post_type
|
||||
-- Commande à exécuter manuellement sur votre serveur privé :
|
||||
-- ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both';
|
||||
|
||||
-- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'user');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Créer l'utilisateur admin par défaut (username: admin, password: admin)
|
||||
-- Note: Le hash bcrypt pour "admin" est généré avec un salt de 10
|
||||
INSERT INTO users (id, username, password, role)
|
||||
VALUES (
|
||||
'admin-user-id',
|
||||
'admin',
|
||||
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||
'admin'
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Créer un utilisateur demo pour les tests
|
||||
INSERT INTO users (id, username, password, role)
|
||||
VALUES (
|
||||
'demo-user',
|
||||
'demo',
|
||||
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||
'user'
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Créer la table user_page_permissions (permissions d'accès aux pages par utilisateur)
|
||||
CREATE TABLE IF NOT EXISTS user_page_permissions (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
-- Index pour optimiser les requêtes de permissions
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id);
|
||||
|
||||
-- Liste des tables créées automatiquement par Drizzle ORM :
|
||||
-- - users (utilisateurs avec rôles)
|
||||
-- - social_pages (pages Facebook/Instagram connectées)
|
||||
-- - cloudinary_config (configuration Cloudinary par utilisateur)
|
||||
-- - openrouter_config (configuration OpenRouter par utilisateur)
|
||||
-- - media (fichiers médias stockés dans Cloudinary)
|
||||
-- - posts (publications créées)
|
||||
-- - scheduled_posts (publications programmées)
|
||||
-- - ai_generations (historique des générations IA)
|
||||
-- - post_media (relation many-to-many entre posts et media)
|
||||
-- - user_page_permissions (permissions d'accès aux pages par utilisateur)
|
||||
@@ -1,233 +0,0 @@
|
||||
-- ============================================================
|
||||
-- MIGRATION SQL POUR SERVEUR DOCKER EXISTANT
|
||||
-- Script de mise à jour pour Social Flow
|
||||
-- ============================================================
|
||||
-- Ce fichier contient toutes les commandes SQL pour mettre à jour
|
||||
-- une base de données existante vers la dernière version du schéma
|
||||
-- ============================================================
|
||||
|
||||
-- ÉTAPE 1: Créer les ENUMs s'ils n'existent pas déjà
|
||||
-- ============================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE platform AS ENUM ('facebook', 'instagram');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE post_type AS ENUM ('feed', 'story', 'both');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE post_status AS ENUM ('draft', 'scheduled', 'published', 'failed');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE media_type AS ENUM ('image', 'video');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'user');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- IMPORTANT: Ajouter 'both' à post_type si serveur existant
|
||||
-- Cette valeur a été ajoutée récemment
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
WHEN others THEN null;
|
||||
END $$;
|
||||
|
||||
-- ÉTAPE 2: Créer les tables principales
|
||||
-- ============================================================
|
||||
|
||||
-- Table users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
-- Table social_pages
|
||||
CREATE TABLE IF NOT EXISTS social_pages (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
platform platform NOT NULL,
|
||||
page_id TEXT NOT NULL,
|
||||
page_name TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
followers_count INTEGER DEFAULT 0,
|
||||
is_active TEXT NOT NULL DEFAULT 'true',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table cloudinary_config
|
||||
CREATE TABLE IF NOT EXISTS cloudinary_config (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
cloud_name TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
api_secret TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table openrouter_config
|
||||
CREATE TABLE IF NOT EXISTS openrouter_config (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
api_key TEXT NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5',
|
||||
system_prompt TEXT NOT NULL DEFAULT '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.',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table media
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type media_type NOT NULL,
|
||||
cloudinary_public_id TEXT,
|
||||
original_url TEXT NOT NULL,
|
||||
facebook_landscape_url TEXT,
|
||||
facebook_feed_url TEXT,
|
||||
instagram_feed_url TEXT,
|
||||
instagram_story_url TEXT,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table posts
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
ai_generated TEXT NOT NULL DEFAULT 'false',
|
||||
product_info JSONB,
|
||||
status post_status NOT NULL DEFAULT 'draft',
|
||||
scheduled_for TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table post_media
|
||||
CREATE TABLE IF NOT EXISTS post_media (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
media_id VARCHAR NOT NULL REFERENCES media(id) ON DELETE CASCADE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Table scheduled_posts
|
||||
CREATE TABLE IF NOT EXISTS scheduled_posts (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
|
||||
post_type post_type NOT NULL,
|
||||
scheduled_at TIMESTAMP NOT NULL,
|
||||
published_at TIMESTAMP,
|
||||
external_post_id TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Table ai_generations
|
||||
CREATE TABLE IF NOT EXISTS ai_generations (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
product_info JSONB NOT NULL,
|
||||
generated_texts JSONB NOT NULL,
|
||||
selected_text TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table user_page_permissions
|
||||
CREATE TABLE IF NOT EXISTS user_page_permissions (
|
||||
id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, page_id)
|
||||
);
|
||||
|
||||
-- ÉTAPE 3: Créer les index pour optimiser les performances
|
||||
-- ============================================================
|
||||
|
||||
-- Index pour user_page_permissions
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id);
|
||||
|
||||
-- Index pour scheduled_posts
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_scheduled_at ON scheduled_posts(scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_post_id ON scheduled_posts(post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_posts_page_id ON scheduled_posts(page_id);
|
||||
|
||||
-- Index pour posts
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_scheduled_for ON posts(scheduled_for);
|
||||
|
||||
-- Index pour media
|
||||
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
|
||||
|
||||
-- ÉTAPE 4: Créer les utilisateurs par défaut
|
||||
-- ============================================================
|
||||
|
||||
-- Créer l'utilisateur admin (username: admin, password: admin)
|
||||
INSERT INTO users (id, username, password, role)
|
||||
VALUES (
|
||||
'admin-user-id',
|
||||
'admin',
|
||||
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||
'admin'
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Créer un utilisateur demo (username: demo, password: admin)
|
||||
INSERT INTO users (id, username, password, role)
|
||||
VALUES (
|
||||
'demo-user',
|
||||
'demo',
|
||||
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||
'user'
|
||||
) ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- ÉTAPE 5: Mise à jour des posts programmés existants (si migration)
|
||||
-- ============================================================
|
||||
-- Mettre à jour le statut des posts programmés qui étaient en "draft"
|
||||
UPDATE posts
|
||||
SET status = 'scheduled'
|
||||
WHERE scheduled_for IS NOT NULL
|
||||
AND status = 'draft';
|
||||
|
||||
-- ============================================================
|
||||
-- FIN DE LA MIGRATION
|
||||
-- ============================================================
|
||||
--
|
||||
-- INSTRUCTIONS D'UTILISATION:
|
||||
--
|
||||
-- 1. Pour un NOUVEAU serveur Docker:
|
||||
-- - Rien à faire ! Le docker-compose.yml exécute automatiquement
|
||||
-- "drizzle-kit push --force" qui crée toutes les tables
|
||||
--
|
||||
-- 2. Pour un serveur Docker EXISTANT avec données:
|
||||
-- - Connectez-vous à PostgreSQL:
|
||||
-- docker exec -it socialflow-db psql -U socialflow -d socialflow
|
||||
-- - Exécutez ce fichier:
|
||||
-- \i /chemin/vers/migration-docker.sql
|
||||
--
|
||||
-- 3. Alternative avec commande directe:
|
||||
-- docker exec -i socialflow-db psql -U socialflow -d socialflow < migration-docker.sql
|
||||
--
|
||||
-- ============================================================
|
||||
@@ -1,151 +0,0 @@
|
||||
CREATE TYPE "public"."media_type" AS ENUM('image', 'video');--> statement-breakpoint
|
||||
CREATE TYPE "public"."platform" AS ENUM('facebook', 'instagram');--> statement-breakpoint
|
||||
CREATE TYPE "public"."post_status" AS ENUM('draft', 'scheduled', 'published', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."post_type" AS ENUM('feed', 'story', 'both');--> statement-breakpoint
|
||||
CREATE TYPE "public"."token_status" AS ENUM('valid', 'expiring', 'expired', 'error');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');--> statement-breakpoint
|
||||
CREATE TABLE "ai_generations" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"product_info" jsonb NOT NULL,
|
||||
"generated_texts" jsonb NOT NULL,
|
||||
"selected_text" text,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cloudinary_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"cloud_name" text NOT NULL,
|
||||
"api_key" text NOT NULL,
|
||||
"api_secret" text NOT NULL,
|
||||
"logo_public_id" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "cloudinary_config_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "media" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"type" "media_type" NOT NULL,
|
||||
"cloudinary_public_id" text,
|
||||
"original_url" text NOT NULL,
|
||||
"facebook_landscape_url" text,
|
||||
"facebook_feed_url" text,
|
||||
"instagram_feed_url" text,
|
||||
"instagram_story_url" text,
|
||||
"file_name" text NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "openrouter_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"api_key" text NOT NULL,
|
||||
"model" text DEFAULT 'anthropic/claude-3.5-sonnet' NOT NULL,
|
||||
"system_prompt" text DEFAULT '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.' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "openrouter_config_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "page_analytics_history" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"page_id" varchar NOT NULL,
|
||||
"date" timestamp NOT NULL,
|
||||
"followers_count" integer DEFAULT 0,
|
||||
"page_views" integer DEFAULT 0,
|
||||
"page_reach" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "post_analytics" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL,
|
||||
"fetched_at" timestamp DEFAULT now(),
|
||||
"impressions" integer DEFAULT 0,
|
||||
"reach" integer DEFAULT 0,
|
||||
"engagement" integer DEFAULT 0,
|
||||
"reactions" integer DEFAULT 0,
|
||||
"comments" integer DEFAULT 0,
|
||||
"shares" integer DEFAULT 0,
|
||||
"clicks" integer DEFAULT 0,
|
||||
"raw_data" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "post_media" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL,
|
||||
"media_id" varchar NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "posts" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"ai_generated" text DEFAULT 'false' NOT NULL,
|
||||
"product_info" jsonb,
|
||||
"status" "post_status" DEFAULT 'draft' NOT NULL,
|
||||
"scheduled_for" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scheduled_posts" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"post_id" varchar NOT NULL,
|
||||
"page_id" varchar NOT NULL,
|
||||
"post_type" "post_type" NOT NULL,
|
||||
"scheduled_at" timestamp NOT NULL,
|
||||
"published_at" timestamp,
|
||||
"external_post_id" text,
|
||||
"error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "social_pages" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"platform" "platform" NOT NULL,
|
||||
"page_id" text NOT NULL,
|
||||
"page_name" text NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"token_expires_at" timestamp,
|
||||
"followers_count" integer DEFAULT 0,
|
||||
"is_active" text DEFAULT 'true' NOT NULL,
|
||||
"token_status" "token_status" DEFAULT 'valid',
|
||||
"last_token_check" timestamp,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_page_permissions" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" varchar NOT NULL,
|
||||
"page_id" varchar NOT NULL,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password" text NOT NULL,
|
||||
"role" "user_role" DEFAULT 'user' NOT NULL,
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ai_generations" ADD CONSTRAINT "ai_generations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cloudinary_config" ADD CONSTRAINT "cloudinary_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "media" ADD CONSTRAINT "media_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "openrouter_config" ADD CONSTRAINT "openrouter_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "page_analytics_history" ADD CONSTRAINT "page_analytics_history_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "post_analytics" ADD CONSTRAINT "post_analytics_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "post_media" ADD CONSTRAINT "post_media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "post_media" ADD CONSTRAINT "post_media_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "social_pages" ADD CONSTRAINT "social_pages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1768914136854,
|
||||
"tag": "0000_spicy_cargill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+89
-8090
File diff suppressed because it is too large.
Load diff
+4
-48
@@ -4,22 +4,19 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
||||
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"dev": "vite",
|
||||
"dev:server": "cargo run --manifest-path server/Cargo.toml",
|
||||
"build": "vite build",
|
||||
"start": "cargo run --release --manifest-path server/Cargo.toml",
|
||||
"check": "tsc",
|
||||
"db:push": "drizzle-kit push",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1016.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
@@ -47,46 +44,20 @@
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@remotion/bundler": "^4.0.438",
|
||||
"@remotion/cli": "^4.0.438",
|
||||
"@remotion/player": "^4.0.438",
|
||||
"@remotion/renderer": "^4.0.438",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@twemoji/api": "^17.0.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/pg": "^8.15.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"canvas": "^2.11.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"helmet": "^8.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
"multer": "^2.0.2",
|
||||
"music-metadata": "^11.12.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -97,15 +68,12 @@
|
||||
"react-webcam": "^7.2.0",
|
||||
"recharts": "^2.15.2",
|
||||
"remotion": "^4.0.438",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"twemoji": "^14.0.2",
|
||||
"twemoji-parser": "^14.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
@@ -115,27 +83,15 @@
|
||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "^5.4.20",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
# Bundle construit par `node server.mjs --prepare`
|
||||
bundle
|
||||
@@ -0,0 +1,66 @@
|
||||
# Service de rendu Remotion des Reels (sous-titres animés, logo, effet de fin).
|
||||
# Contexte de build : la racine du dépôt (les compositions vivent dans
|
||||
# client/src/remotion et importent shared/captions.ts).
|
||||
# docker build -f remotion-service/Dockerfile .
|
||||
|
||||
# Debian slim — glibc requis par le Chrome headless shell de Remotion
|
||||
FROM node:20-slim
|
||||
|
||||
# Dépendances du Chrome headless shell et polices de secours
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
fonts-dejavu-core \
|
||||
fontconfig \
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dépendances (versions épinglées, identiques au package.json racine)
|
||||
COPY remotion-service/package.json remotion-service/package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
# Compositions et module partagé qu'elles importent (@shared/captions).
|
||||
# La police Montserrat vient du paquet @fontsource/montserrat.
|
||||
COPY client/src/remotion ./client/src/remotion
|
||||
COPY shared/captions.ts ./shared/captions.ts
|
||||
COPY remotion-service/server.mjs ./server.mjs
|
||||
|
||||
# Pré-bundle des compositions et téléchargement du Chrome headless shell
|
||||
# (équivalent de `npx remotion browser ensure`) : aucun accès réseau au
|
||||
# premier rendu.
|
||||
RUN node server.mjs --prepare && rm -rf /tmp/remotion-*
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8100 \
|
||||
OUTPUT_DIR=/tmp/remotion-renders
|
||||
|
||||
# Utilisateur non privilégié ; seuls /tmp et le cache Chrome sont utilisés
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
CMD ["node", "server.mjs"]
|
||||
Generated
+2931
File diff suppressed because it is too large.
Load diff
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user