From 3b28fd0c67db1ded7471dbab35aa48a859e6a34d Mon Sep 17 00:00:00 2001 From: arolleaguekeng Date: Tue, 7 Apr 2026 23:14:42 +0100 Subject: [PATCH 1/7] add AppGen landing page with authentication flow, update vite dev port to 5174, integrate appgen routes in API, implement chat history persistence with localStorage sync, add deploy modal component, and update AuthWrapper to support intent-based login redirects --- .gitignore | 1 + apps/api/api/controllers/appgen.controller.ts | 113 +++++++ apps/api/api/index.ts | 4 + apps/api/api/routes/appgen.routes.ts | 79 +++++ apps/appgen/apps/we-dev-client/package.json | 4 +- apps/appgen/apps/we-dev-client/src/App.tsx | 147 ++++++-- .../src/components/AiChat/chat/index.tsx | 29 +- .../src/components/AuthWrapper.tsx | 20 +- .../ChatHistory/ChatHistoryPanel.tsx | 128 +++++++ .../components/DeployModal/DeployModal.tsx | 319 ++++++++++++++++++ .../src/components/Header/HeaderActions.tsx | 188 ++--------- .../src/components/Header/ProjectTitle.tsx | 119 ++++--- .../src/components/Header/UserProfile.tsx | 126 +++++-- .../src/components/Landing/AppGenLanding.tsx | 231 +++++++++++++ .../apps/we-dev-client/src/hooks/useAuth.ts | 94 ++++++ .../apps/we-dev-client/src/hooks/useInit.ts | 294 ++++++++-------- .../src/stores/appgenContextSlice.ts | 155 +++++++++ .../src/stores/chatHistoryStore.ts | 87 +++++ apps/appgen/apps/we-dev-next/eslint.config.js | 4 + .../apps/we-dev-next/src/routes/handoff.ts | 102 ++++++ apps/appgen/apps/we-dev-next/src/server.ts | 4 + .../pages/create-project/create-project.html | 42 ++- .../pages/create-project/create-project.ts | 46 ++- 23 files changed, 1912 insertions(+), 424 deletions(-) create mode 100644 apps/api/api/controllers/appgen.controller.ts create mode 100644 apps/api/api/routes/appgen.routes.ts create mode 100644 apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx create mode 100644 apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx create mode 100644 apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx create mode 100644 apps/appgen/apps/we-dev-client/src/hooks/useAuth.ts create mode 100644 apps/appgen/apps/we-dev-client/src/stores/appgenContextSlice.ts create mode 100644 apps/appgen/apps/we-dev-client/src/stores/chatHistoryStore.ts create mode 100644 apps/appgen/apps/we-dev-next/src/routes/handoff.ts diff --git a/.gitignore b/.gitignore index 0dbff760e..735bf2599 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ data/ docker-compose.staging.yml .windsurf +apps/ideploy/public/hot diff --git a/apps/api/api/controllers/appgen.controller.ts b/apps/api/api/controllers/appgen.controller.ts new file mode 100644 index 000000000..05ad19cfe --- /dev/null +++ b/apps/api/api/controllers/appgen.controller.ts @@ -0,0 +1,113 @@ +import { Response } from 'express'; +import { CustomRequest } from '../interfaces/express.interface'; +import { v4 as uuidv4 } from 'uuid'; +import RedisConnection from '../config/redis.config'; +import logger from '../config/logger'; + +const HANDOFF_TTL_SECONDS = 15 * 60; // 15 minutes +const HANDOFF_KEY_PREFIX = 'appgen:handoff:'; + +/** + * POST /appgen/handoff + * Stores an AppGen generation payload in Redis and returns a handoffId. + * iDeploy reads it at GET /api/ideploy/handoff/:handoffId + */ +export const createHandoffController = async ( + req: CustomRequest, + res: Response +): Promise => { + const userId = req.user?.uid; + logger.info('AppGen handoff requested', { userId }); + + try { + const { draftId, appName, description, files, metadata, messages, generatedAt, source, target, expiresAt } = + req.body; + + if (!files || typeof files !== 'object') { + res.status(400).json({ success: false, message: 'files is required' }); + return; + } + + const handoffId = uuidv4(); + const handoffData = { + handoffId, + userId: userId || null, + draftId, + appName: appName || 'Mon Application', + description: description || '', + files, + metadata: metadata || {}, + messages: messages || [], + generatedAt: generatedAt || new Date().toISOString(), + createdAt: new Date().toISOString(), + source: source || 'appgen', + target: target || 'ideploy', + expiresAt: expiresAt || new Date(Date.now() + HANDOFF_TTL_SECONDS * 1000).toISOString(), + }; + + try { + const redis = RedisConnection.getInstance(); + await redis.set( + `${HANDOFF_KEY_PREFIX}${handoffId}`, + JSON.stringify(handoffData), + 'EX', + HANDOFF_TTL_SECONDS + ); + logger.info('Handoff stored in Redis', { handoffId, userId }); + } catch (redisError: any) { + logger.warn('Redis unavailable, handoff stored only in response', { + error: redisError.message, + }); + // Still return the handoffId — iDeploy will fall back to sessionStorage on client side + } + + res.status(201).json({ success: true, handoffId }); + } catch (error: any) { + logger.error('Error in createHandoffController:', { + userId, + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: 'Failed to create handoff', + error: error.message, + }); + } +}; + +/** + * GET /appgen/handoff/:handoffId + * Retrieves a handoff payload by ID (consumed by iDeploy). + */ +export const getHandoffController = async ( + req: CustomRequest, + res: Response +): Promise => { + const { handoffId } = req.params; + logger.info('AppGen handoff fetch', { handoffId }); + + try { + const redis = RedisConnection.getInstance(); + const raw = await redis.get(`${HANDOFF_KEY_PREFIX}${handoffId}`); + + if (!raw) { + res.status(404).json({ success: false, message: 'Handoff not found or expired' }); + return; + } + + const handoffData = JSON.parse(raw); + res.status(200).json({ success: true, data: handoffData }); + } catch (error: any) { + logger.error('Error in getHandoffController:', { + handoffId, + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: 'Failed to retrieve handoff', + error: error.message, + }); + } +}; diff --git a/apps/api/api/index.ts b/apps/api/api/index.ts index 5ffacbc9c..e36eef22d 100644 --- a/apps/api/api/index.ts +++ b/apps/api/api/index.ts @@ -66,6 +66,7 @@ import { teamsRoutes } from './routes/teams.routes'; import contactRoutes from './routes/contactRoutes'; import logoImportRoutes from './routes/logo-import.routes'; import ideployRoutes from './routes/ideploy.routes'; +import appgenRoutes from './routes/appgen.routes'; const app: Express = express(); @@ -155,6 +156,9 @@ app.use('/api/logo', logoImportRoutes); // iDeploy routes app.use('/api/ideploy', ideployRoutes); +// AppGen routes +app.use('/appgen', appgenRoutes); + // Swagger setup const swaggerSpec = swaggerJsdoc(swaggerOptions); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); diff --git a/apps/api/api/routes/appgen.routes.ts b/apps/api/api/routes/appgen.routes.ts new file mode 100644 index 000000000..da70a5709 --- /dev/null +++ b/apps/api/api/routes/appgen.routes.ts @@ -0,0 +1,79 @@ +import { Router } from 'express'; +import { authenticate } from '../services/auth.service'; +import { createHandoffController, getHandoffController } from '../controllers/appgen.controller'; + +const router = Router(); + +/** + * @swagger + * /appgen/handoff: + * post: + * summary: Crée un handoff AppGen vers iDeploy + * tags: [AppGen] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [files] + * properties: + * draftId: + * type: string + * appName: + * type: string + * description: + * type: string + * files: + * type: object + * metadata: + * type: object + * messages: + * type: array + * responses: + * 201: + * description: Handoff créé avec succès + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * handoffId: + * type: string + * 400: + * description: Données invalides + * 401: + * description: Non authentifié + * 500: + * description: Erreur serveur + */ +router.post('/handoff', authenticate, createHandoffController); + +/** + * @swagger + * /appgen/handoff/{handoffId}: + * get: + * summary: Récupère un handoff AppGen par son ID + * tags: [AppGen] + * parameters: + * - in: path + * name: handoffId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Handoff trouvé + * 404: + * description: Handoff non trouvé ou expiré + * 500: + * description: Erreur serveur + */ +router.get('/handoff/:handoffId', getHandoffController); + +export default router; diff --git a/apps/appgen/apps/we-dev-client/package.json b/apps/appgen/apps/we-dev-client/package.json index 43a731aa2..cd567f82c 100644 --- a/apps/appgen/apps/we-dev-client/package.json +++ b/apps/appgen/apps/we-dev-client/package.json @@ -8,8 +8,8 @@ "email": "your.email@example.com" }, "scripts": { - "dev": "vite", - "build": "vite build", + "dev": "vite --port 5174", + "build": "vite build ", "tsc": "tsc", "start": "vite preview --host 0.0.0.0" }, diff --git a/apps/appgen/apps/we-dev-client/src/App.tsx b/apps/appgen/apps/we-dev-client/src/App.tsx index d33d26250..70a181e27 100644 --- a/apps/appgen/apps/we-dev-client/src/App.tsx +++ b/apps/appgen/apps/we-dev-client/src/App.tsx @@ -1,36 +1,137 @@ -import useUserStore from "./stores/userSlice"; -import useChatModeStore from "./stores/chatModeSlice"; -import { GlobalLimitModal } from "./components/UserModal"; -import Header from "./components/Header"; -import AiChat from "./components/AiChat"; -import EditorPreviewTabs from "./components/EditorPreviewTabs"; -import "./utils/i18"; -import classNames from "classnames"; -import { ChatMode } from "./types/chat"; -import { ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { UpdateTip } from "./components/UpdateTip"; -import useInit from "./hooks/useInit"; -import { Loading } from "./components/loading"; -import TopViewContainer from "./components/TopView"; +import { useState, useEffect } from 'react'; +import useUserStore from './stores/userSlice'; +import useChatModeStore from './stores/chatModeSlice'; +import { GlobalLimitModal } from './components/UserModal'; +import Header from './components/Header'; +import AiChat from './components/AiChat'; +import EditorPreviewTabs from './components/EditorPreviewTabs'; +import './utils/i18'; +import classNames from 'classnames'; +import { ChatMode } from './types/chat'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { UpdateTip } from './components/UpdateTip'; +import useInit from './hooks/useInit'; +import { Loading } from './components/loading'; +import TopViewContainer from './components/TopView'; +import { AppGenLanding } from './components/Landing/AppGenLanding'; +import useAppGenContextStore from './stores/appgenContextSlice'; +import { consumePendingContext } from './hooks/useAuth'; +import { getCurrentUser } from './api/persistence/db'; + +const PENDING_PROMPT_KEY = 'appgen_pending_prompt'; + +// View states managed by App +type AppView = 'loading' | 'landing' | 'chat'; function App() { const { mode, initOpen } = useChatModeStore(); + const { openLoginModal, isAuthenticated } = useUserStore(); + const { isDarkMode } = useInit(); + const { initDraft, setPendingIntent, updateDraftMetadata } = useAppGenContextStore(); - const { openLoginModal } = useUserStore(); + const [view, setView] = useState('loading'); - const { isDarkMode } = useInit(); + useEffect(() => { + // URL params take priority — preserve all existing workflows + const urlParams = new URLSearchParams(window.location.search); + const projectId = urlParams.get('projectId'); + const fromParam = urlParams.get('from'); + const promptParam = urlParams.get('prompt'); + + // Restore AppGen context if returning from login redirect + const pendingCtx = consumePendingContext(); + if (pendingCtx?.intent) { + setPendingIntent(pendingCtx.intent); + } + + initDraft(); + + // Skip landing entirely for existing workflows: + // - projectId: linked from main-dashboard + // - prompt: coming from landing start + // - from=dashboard / from=appgen: returning after auth (restore pending prompt if any) + if (projectId || promptParam || fromParam === 'dashboard' || fromParam === 'appgen') { + // If returning from login with a pending prompt, inject it via URL param + if (!promptParam) { + const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY); + if (pendingPrompt) { + localStorage.removeItem(PENDING_PROMPT_KEY); + const url = new URL(window.location.href); + url.searchParams.set('prompt', encodeURIComponent(pendingPrompt)); + window.history.replaceState({}, '', url.toString()); + } + } + setView('chat'); + return; + } + + // Use the same auth check as AuthWrapper: getCurrentUser() via session cookie + getCurrentUser().then((user) => { + setView(user ? 'chat' : 'landing'); + }); + }, []); + + // When user logs in via the login modal, switch from landing to chat + useEffect(() => { + if (isAuthenticated && view === 'landing') { + setView('chat'); + } + }, [isAuthenticated]); + + const handleLandingStart = (prompt?: string) => { + updateDraftMetadata({}); + if (prompt) { + const url = new URL(window.location.href); + url.searchParams.set('prompt', encodeURIComponent(prompt)); + window.history.replaceState({}, '', url.toString()); + } + setView('chat'); + }; + + // Minimal loading screen while checking auth + if (view === 'loading') { + return ( +
+
+ logo +
+
+
+ ); + } + + // Landing page — non-authenticated entry point + if (view === 'landing') { + return ( +
+ + +
+ ); + } + // Chat — full app (authenticated or projectId workflow) return (
diff --git a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx index e8b878e57..83fa5f7fe 100644 --- a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx @@ -40,6 +40,7 @@ import { ProjectTutorial } from '../../Onboarding/ProjectTutorial'; import { useLoading } from '../../loading'; import { ProjectModel } from '@/api/persistence/models/project.model'; import { MultiChatPromptService } from './services/multiChatPromptService'; +import useChatHistoryStore from '@/stores/chatHistoryStore'; type WeMessages = (Message & { experimental_attachments?: Array<{ @@ -202,6 +203,7 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { setOldFiles, } = useFileStore(); const { mode } = useChatModeStore(); + const { touchSession, updateSessionTitle, setActiveChatUuid } = useChatHistoryStore(); // use global state const { uploadedImages, addImages, removeImage, clearImages, setModelOptions } = useChatStore(); const { resetTerminals } = useTerminalStore(); @@ -589,13 +591,18 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { content: input, }, ]; + const autoTitle = + [...initMessage, ...messages] + .find((m) => m.role === 'user' && !m.content.includes(' m.role === 'user' && !m.content.includes(' { }, }); + // Listen for auto-prompt from AppGen landing page (placed here, after useChat, so isLoading/append are defined) + useEffect(() => { + const unsubscribe = eventEmitter.on('chat:autoPrompt', (prompt: string) => { + if (prompt && !isLoading) { + append({ + id: uuidv4(), + role: 'user', + content: prompt, + }); + } + }); + return () => unsubscribe(); + }, [isLoading, append]); + // Get status and type from URL data (projectId already obtained above) const { status, type } = useUrlData({ append }); const { setLoading } = useLoading(); diff --git a/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx b/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx index 8b2fc380b..a41f2c9c0 100644 --- a/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useState } from "react"; -import { getCurrentUser } from "../api/persistence/db"; -import { Loading } from "./loading"; +import React, { useEffect, useState } from 'react'; +import { getCurrentUser } from '../api/persistence/db'; +import { Loading } from './loading'; +import { redirectToLogin } from '../hooks/useAuth'; interface AuthWrapperProps { children: React.ReactNode; + intent?: string; } -const AuthWrapper: React.FC = ({ children }) => { +const AuthWrapper: React.FC = ({ children, intent }) => { const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -16,7 +18,7 @@ const AuthWrapper: React.FC = ({ children }) => { const user = await getCurrentUser(); setIsAuthenticated(!!user); } catch (error) { - console.error("Authentication check failed:", error); + console.error('Authentication check failed:', error); setIsAuthenticated(false); } finally { setIsLoading(false); @@ -27,19 +29,17 @@ const AuthWrapper: React.FC = ({ children }) => { }, []); useEffect(() => { - // Redirect to login if not authenticated if (isAuthenticated === false) { - console.log("Redirecting to login"); - // window.location.href = "http://localhost:4200/login"; + redirectToLogin(intent); } - }, [isAuthenticated]); + }, [isAuthenticated, intent]); if (isLoading) { return ; } if (!isAuthenticated) { - return null; // Will redirect, so don't render anything + return null; } return <>{children}; diff --git a/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx b/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx new file mode 100644 index 000000000..14cf571de --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef } from 'react'; +import useChatHistoryStore, { ChatSession } from '@/stores/chatHistoryStore'; +import { db } from '@/utils/indexDB'; +import { eventEmitter } from '@/components/AiChat/utils/EventEmitter'; + +interface ChatHistoryPanelProps { + open: boolean; + onClose: () => void; +} + +function formatDate(ts: number): string { + const d = new Date(ts); + const now = new Date(); + const diff = now.getTime() - ts; + if (diff < 60_000) return "A l'instant"; + if (diff < 3600_000) return `Il y a ${Math.floor(diff / 60_000)} min`; + if (d.toDateString() === now.toDateString()) + return `Aujourd'hui ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return 'Hier'; + return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); +} + +const ChatHistoryPanel: React.FC = ({ open, onClose }) => { + const { sessions, activeChatUuid, deleteSession, createSession, setActiveChatUuid } = + useChatHistoryStore(); + const panelRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open, onClose]); + + const handleSelectChat = (session: ChatSession) => { + setActiveChatUuid(session.uuid); + eventEmitter.emit('chat:select', session.uuid); + onClose(); + }; + + const handleNewChat = () => { + const uuid = createSession(); + eventEmitter.emit('chat:select', ''); + setActiveChatUuid(uuid); + onClose(); + }; + + const handleDeleteSession = async (e: React.MouseEvent, uuid: string) => { + e.stopPropagation(); + await db.deleteByUuid(uuid); + deleteSession(uuid); + // If deleted active chat, start fresh + if (uuid === activeChatUuid) { + eventEmitter.emit('chat:select', ''); + } + }; + + if (!open) return null; + + return ( +
+ {/* Header */} +
+ Historique des chats + +
+ + {/* List */} +
+ {sessions.length === 0 ? ( +
+ Aucun chat pour l'instant +
+ ) : ( + sessions.map((session) => ( + + + )) + )} +
+
+ ); +}; + +export default ChatHistoryPanel; diff --git a/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx b/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx new file mode 100644 index 000000000..82daa352a --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx @@ -0,0 +1,319 @@ +import { useState, useEffect } from 'react'; +import { Modal } from 'antd'; +import { toast } from 'react-toastify'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import useUserStore from '@/stores/userSlice'; +import { redirectToLogin } from '@/hooks/useAuth'; +import { getCurrentUser } from '@/api/persistence/db'; +import type { UserModel } from '@/api/persistence/userModel'; + +interface DeployModalProps { + open: boolean; + onClose: () => void; + onNetlifyDeploy: () => void; +} + +const IDEPLOY_URL = process.env.REACT_APP_IDEPLOY_URL || 'http://localhost:8000'; +const DASHBOARD_URL = process.env.REACT_APP_IDEM_MAIN_APP_URL || 'http://localhost:4200'; +const API_BASE = process.env.REACT_APP_IDEM_API_BASE_URL || 'http://localhost:3001'; + +const HANDOFF_TTL_MS = 15 * 60 * 1000; // 15 minutes + +export function DeployModal({ open, onClose, onNetlifyDeploy }: DeployModalProps) { + const [isHandingOff, setIsHandingOff] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const { getHandoffPayload } = useAppGenContextStore(); + const { token } = useUserStore(); + + useEffect(() => { + if (open) { + getCurrentUser().then((user) => setCurrentUser(user)); + } + }, [open]); + + const handleNetlify = () => { + onClose(); + onNetlifyDeploy(); + }; + + const handleIdemDeploy = async () => { + if (!currentUser) { + redirectToLogin('deploy_idem'); + onClose(); + return; + } + + const payload = getHandoffPayload(); + if (!payload) { + toast.error('Aucune génération disponible à déployer'); + return; + } + + setIsHandingOff(true); + try { + const response = await fetch(`${API_BASE}/appgen/handoff`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + body: JSON.stringify({ + ...payload, + target: 'ideploy', + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const { handoffId } = await response.json(); + onClose(); + window.location.href = `${IDEPLOY_URL}/deploy/from-appgen?handoffId=${handoffId}`; + } catch (error) { + console.error('Handoff failed:', error); + // Fallback: pass payload via sessionStorage + redirect + const payload = getHandoffPayload(); + if (payload) { + sessionStorage.setItem( + 'appgen_handoff', + JSON.stringify({ + ...payload, + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }) + ); + } + onClose(); + window.location.href = `${IDEPLOY_URL}/deploy/from-appgen?source=appgen`; + } finally { + setIsHandingOff(false); + } + }; + + const handleConnectProject = () => { + if (!currentUser) { + redirectToLogin('connect_project'); + onClose(); + return; + } + + const payload = getHandoffPayload(); + if (payload) { + sessionStorage.setItem( + 'appgen_handoff', + JSON.stringify({ + ...payload, + target: 'dashboard', + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }) + ); + } + + onClose(); + const encodedName = encodeURIComponent(payload?.appName || 'Mon Application'); + const encodedDesc = encodeURIComponent(payload?.description || ''); + window.location.href = `${DASHBOARD_URL}/create-project?from=appgen&name=${encodedName}&description=${encodedDesc}`; + }; + + return ( + +
+ {/* Header */} +
+
+ + + +
+

Déployer votre application

+

Choisissez comment vous souhaitez déployer

+
+ + {/* Options */} +
+ {/* Quick deploy - Netlify */} + + + {/* iDeploy */} + +
+ + {/* Connect to project */} +
+ +
+ + {/* Cancel */} +
+ +
+
+
+ ); +} + +export default DeployModal; diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx index 293d4ba17..941f8774e 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx @@ -5,11 +5,15 @@ import useChatModeStore from '@/stores/chatModeSlice'; import { ChatMode } from '@/types/chat'; import useTerminalStore from '@/stores/terminalSlice'; import { getWebContainerInstance } from '../WeIde/services/webcontainer'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import { Modal } from 'antd'; -import { sendToGitHub } from '@/api/persistence/db'; +import { sendToGitHub, getCurrentUser } from '@/api/persistence/db'; import { HelpButton } from './HelpButton'; +import { DeployModal } from '../DeployModal/DeployModal'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import { UserProfile } from './UserProfile'; +import type { UserModel } from '@/api/persistence/userModel'; // Add a helper function to recursively get all files const getAllFiles = async ( @@ -98,23 +102,17 @@ export function HeaderActions() { } }; - const handleDeployClick = () => { - setShowDeployChoiceModal(true); - }; + const { updateDraftFiles } = useAppGenContextStore(); + const [currentUser, setCurrentUser] = useState(null); - const handleDeployChoice = (choice: 'netlify' | 'idem') => { - setShowDeployChoiceModal(false); + useEffect(() => { + getCurrentUser().then((user) => setCurrentUser(user)); + }, []); - if (choice === 'idem') { - const idemUrl = process.env.REACT_APP_IDEM_MAIN_APP_URL; - if (idemUrl) { - window.open(`${idemUrl}/console/deployments`, '_blank'); - } else { - toast.error('REACT_APP_IDEM_MAIN_APP_URL not configured'); - } - } else { - publishToNetlify(); - } + const handleDeployClick = () => { + setShowDeployChoiceModal(true); + // Sync current files into AppGen context before deploying + updateDraftFiles(files as Record); }; const publishToNetlify = async () => { @@ -216,6 +214,7 @@ export function HeaderActions() { return (
+ {currentUser && } {mode === ChatMode.Builder && (
@@ -308,6 +307,12 @@ export function HeaderActions() { {/* Directory opening option disabled in web mode */}
)} + setShowDeployChoiceModal(false)} + onNetlifyDeploy={publishToNetlify} + /> + {showModal && ( )} - - {/* Deploy Choice Modal */} - {showDeployChoiceModal && ( - setShowDeployChoiceModal(false)} - footer={null} - width={600} - className=" bg-black" - styles={{ - content: { backgroundColor: 'var(--color-bg-light)' }, - body: { - padding: 0, - }, - header: { - display: 'none', - }, - }} - > -
-
- - - -
-

Déployer votre projet

-

Choisissez votre méthode de déploiement

-
- -
- {/* Custom Deployment - Coming Soon */} -
-
-
-
- - - -
-
-

Déploiement personnalisé

- - Disponible bientôt - -
-
- -

- Déployez sur votre infrastructure avec Idem Deploy -

- -
- - AWS - - - Docker - - - Kubernetes - -
-
-
- - {/* Quick Deployment */} - -
- -
- -
-
- )}
); } diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx index d0487f26a..ceb4a30b8 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx @@ -1,57 +1,94 @@ -import React, { useState, useEffect } from "react"; -import { getProjectById } from "../../api/persistence/db"; -import { useUrlData } from "../../hooks/useUrlData"; -import { ProjectModel } from "@/api/persistence/models/project.model"; +import React, { useState, useEffect } from 'react'; +import { getProjectById } from '../../api/persistence/db'; +import { useUrlData } from '../../hooks/useUrlData'; +import { ProjectModel } from '@/api/persistence/models/project.model'; +import ChatHistoryPanel from '@/components/ChatHistory/ChatHistoryPanel'; +import useChatHistoryStore from '@/stores/chatHistoryStore'; export function ProjectTitle() { const [projectData, setProjectData] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); const { projectId } = useUrlData({ append: () => {} }); + const { sessions, activeChatUuid } = useChatHistoryStore(); - // Load project data - const loadProjectData = async () => { - if (!projectId) return; - - try { - const project = await getProjectById(projectId); - setProjectData(project); - } catch (error) { - console.error("Error loading project:", error); - } - }; + const activeSession = sessions.find((s) => s.uuid === activeChatUuid); useEffect(() => { - loadProjectData(); + if (!projectId) return; + getProjectById(projectId) + .then((p) => setProjectData(p)) + .catch((e) => console.error('Error loading project:', e)); }, [projectId]); - return ( -
- {/* Project Icon */} -
- -
- - {/* Project Info */} -
-
- {projectData?.name || "Project"} + // Project mode: show project info + if (projectId) { + return ( +
+
+
-
- Generation Workspace +
+
+ {projectData?.name || 'Project'} +
+
Generation Workspace
+
+
+ Active Project
+ ); + } - {/* Generation Status Badge */} -
- Active Project -
+ // No project: show chat history button + return ( +
+ + + setHistoryOpen(false)} />
); } diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx index dd394c287..a7222a252 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx @@ -1,53 +1,117 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import type { UserModel } from '../../api/persistence/userModel'; +import useUserStore from '@/stores/userSlice'; interface UserProfileProps { user: UserModel; } export const UserProfile: React.FC = ({ user }) => { - // Function to get initials from name (display name or email) - const getInitials = (name: string) => { - return name + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { logout } = useUserStore(); + + const mainAppUrl = process.env.REACT_APP_IDEM_MAIN_APP_URL || 'http://localhost:4200'; + + const getInitials = (name: string) => + name ?.split(' ') - .map((word) => word[0]) + .map((w) => w[0]) .join('') .toUpperCase() .slice(0, 2) || '?'; - }; - // Display name is preferred, fallback to email const displayName = user.displayName || user.email; const initials = getInitials(user.displayName || user.email); + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const handleLogout = () => { + logout(); + setOpen(false); + window.location.reload(); + }; + return ( -
-
+
-
-
- {displayName} +
+ {!user.photoURL && initials}
-
- {user.subscription} plan +
+
+ {displayName} +
+
{user.subscription}
+
+ + + + + + {open && ( +
+
+
{displayName}
+
{user.email}
+
+ setOpen(false)} + > + + + + Dashboard Idem + +
-
+ )}
); }; diff --git a/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx b/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx new file mode 100644 index 000000000..faa10fac5 --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import { getCurrentUser } from '@/api/persistence/db'; +import type { UserModel } from '@/api/persistence/userModel'; +import { UserProfile } from '../Header/UserProfile'; +import { redirectToLogin } from '@/hooks/useAuth'; + +const PENDING_PROMPT_KEY = 'appgen_pending_prompt'; + +interface AppGenLandingProps { + onStart: (prompt?: string) => void; +} + +const EXAMPLE_PROMPTS = [ + 'Une application de gestion de tontines pour les communautés africaines', + 'Un dashboard de suivi des livraisons pour un e-commerce à Dakar', + 'Une landing page pour une startup fintech à Lagos', + 'Une plateforme de mise en relation entre freelances et entreprises à Abidjan', +]; + +export function AppGenLanding({ onStart }: AppGenLandingProps) { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + const [currentUser, setCurrentUser] = useState(null); + const { initDraft } = useAppGenContextStore(); + + useEffect(() => { + getCurrentUser().then((user) => setCurrentUser(user)); + }, []); + + const handleStart = (prompt?: string) => { + const finalPrompt = prompt || inputValue.trim() || undefined; + if (!currentUser) { + // Save prompt so we can restore it after login redirect + if (finalPrompt) { + localStorage.setItem(PENDING_PROMPT_KEY, finalPrompt); + } + redirectToLogin('generate'); + return; + } + initDraft(); + onStart(finalPrompt); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (inputValue.trim()) handleStart(); + } + }; + + return ( +
+ {/* Top bar */} +
+
+ logo + APPGEN + + by Idem + +
+
+ {currentUser ? ( + + ) : ( + + Se connecter + + )} +
+
+ + {/* Header */} +
+ logo + APPGEN + + by Idem + +
+ + {/* Hero */} +
+

+ Générez votre application en quelques secondes +

+

+ Décrivez votre idée, AppGen s'occupe du reste. Sans compte requis pour commencer. +

+
+ + {/* Input */} +
+
+