diff --git a/backend/src/build-system/__tests__/test.frontend-code-generate.spec.ts b/backend/src/build-system/__tests__/test.frontend-code-generate.spec.ts index 58ab32e2..bc425088 100644 --- a/backend/src/build-system/__tests__/test.frontend-code-generate.spec.ts +++ b/backend/src/build-system/__tests__/test.frontend-code-generate.spec.ts @@ -12,7 +12,7 @@ describe('FrontendCodeHandler', () => { name: 'Spotify-like Music Web', description: 'Users can play music', databaseType: 'SQLite', - model: 'o3-mini-high', + model: 'o4-mini', nodes: [ { handler: FrontendCodeHandler, diff --git a/backend/src/build-system/context.ts b/backend/src/build-system/context.ts index 95ae370d..e4006fb7 100644 --- a/backend/src/build-system/context.ts +++ b/backend/src/build-system/context.ts @@ -115,7 +115,7 @@ export class BuilderContext { this.globalContext.set('projectSize', 'small'); break; case 'gpt-4o': - case 'o3-mini-high': + case 'o4-mini': this.globalContext.set('projectSize', 'medium'); break; default: diff --git a/backend/src/build-system/handlers/frontend-code-generate/CodeReview.ts b/backend/src/build-system/handlers/frontend-code-generate/CodeReview.ts index 8f6f1396..4a01ea81 100644 --- a/backend/src/build-system/handlers/frontend-code-generate/CodeReview.ts +++ b/backend/src/build-system/handlers/frontend-code-generate/CodeReview.ts @@ -198,7 +198,7 @@ export class FrontendQueueProcessor { let fixResponse = await chatSyncWithClocker( this.context, { - model: 'o3-mini-high', + model: 'o4-mini', messages: [ { role: 'system', content: fixPrompt }, { @@ -270,7 +270,7 @@ export class FrontendQueueProcessor { fixResponse = await chatSyncWithClocker( this.context, { - model: 'o3-mini-high', + model: 'o4-mini', messages: [ { role: 'system', content: fixPrompt }, { diff --git a/backend/src/build-system/handlers/frontend-code-generate/index.ts b/backend/src/build-system/handlers/frontend-code-generate/index.ts index 502c568f..8f3b943b 100644 --- a/backend/src/build-system/handlers/frontend-code-generate/index.ts +++ b/backend/src/build-system/handlers/frontend-code-generate/index.ts @@ -366,8 +366,8 @@ export class FrontendCodeHandler implements BuildHandler { context, { model: isSPAFlag - ? 'claude-3.7-sonnet' // Use Claude for SPAs - : 'o3-mini-high', // Use default or fallback for non-SPAs + ? 'gpt-4o-mini' // Use Claude for SPAs + : 'o4-mini', // Use default or fallback for non-SPAs messages, }, 'generate frontend code', diff --git a/backend/src/build-system/handlers/ux/uiux-layout/index.ts b/backend/src/build-system/handlers/ux/uiux-layout/index.ts index acbe275d..f694734c 100644 --- a/backend/src/build-system/handlers/ux/uiux-layout/index.ts +++ b/backend/src/build-system/handlers/ux/uiux-layout/index.ts @@ -75,7 +75,7 @@ export class UIUXLayoutHandler implements BuildHandler { context, { // model: context.defaultModel || 'gpt-4o-mini', - model: 'claude-3.7-sonnet', + model: 'gpt-4o-mini', messages, }, 'generateUIUXLayout', diff --git a/backend/src/project/dto/project.input.ts b/backend/src/project/dto/project.input.ts index 4dc9c0be..c55e8e18 100644 --- a/backend/src/project/dto/project.input.ts +++ b/backend/src/project/dto/project.input.ts @@ -124,6 +124,9 @@ export class FetchPublicProjectsInputs { @Field() size: number; + + @Field() + currentUserId: string; } @InputType() diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index c1e899cb..24b0dbed 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -103,12 +103,14 @@ export class ProjectsResolver { const { buffer, mimetype } = await validateAndBufferFile(file); // Call the service with the extracted buffer and mimetype - return this.projectService.updateProjectPhotoUrl( + const project1 = await this.projectService.updateProjectPhotoUrl( userId, projectId, buffer, mimetype, ); + this.logger.debug('project1', project1.photoUrl); + return project1; } @Mutation(() => Project) @@ -152,8 +154,10 @@ export class ProjectsResolver { */ @Query(() => [Project]) async fetchPublicProjects( + @GetUserIdFromToken() userId: string, @Args('input') input: FetchPublicProjectsInputs, ): Promise { + input.currentUserId = userId; return this.projectService.fetchPublicProjects(input); } diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index 216fb495..682537bd 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -7,7 +7,7 @@ import { ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Between, In, IsNull, Not, Repository } from 'typeorm'; +import { Between, In, Not, Repository } from 'typeorm'; import { Project } from './project.model'; import { ProjectPackages } from './project-packages.model'; import { @@ -644,9 +644,8 @@ export class ProjectService { const limit = input.size > 50 ? 50 : input.size; const whereCondition = { - isPublic: true, isDeleted: false, - photoUrl: Not(IsNull()), + userId: Not(input.currentUserId), // Exclude current user's projects }; if (input.strategy === 'latest') { @@ -808,7 +807,7 @@ export class ProjectService { this.logger.log( 'check if the github project exist: ' + project.isSyncedWithGitHub, ); - // 2) Check user’s GitHub installation + // 2) Check user's GitHub installation if (!user.githubInstallationId) { throw new Error('GitHub App not installed for this user'); } @@ -819,7 +818,7 @@ export class ProjectService { ); const userOAuthToken = user.githubAccessToken; - // 4) Create the repo if the project doesn’t have it yet + // 4) Create the repo if the project doesn't have it yet if (!project.githubRepoName || !project.githubOwner) { // Use project.projectName or generate a safe name diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 8267f03e..ebb92550 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -25,7 +25,7 @@ export class User extends SystemBaseModel { googleId: string; @Field() - @Column() + @Column({ unique: true }) username: string; @Column({ nullable: true }) // Made nullable for OAuth users diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 6257e652..0ea9293e 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -107,4 +108,69 @@ export class UserService { return true; } + + /** + * Checks if a username already exists in the database + * @param username Username to check + * @param excludeUserId Optional user ID to exclude from the check (for updates) + * @returns Boolean indicating if the username exists + */ + async isUsernameExists( + username: string, + excludeUserId?: string, + ): Promise { + const query = this.userRepository + .createQueryBuilder('user') + .where('LOWER(user.username) = LOWER(:username)', { + username: username.toLowerCase(), + }); + + if (excludeUserId) { + query.andWhere('user.id != :userId', { userId: excludeUserId }); + } + + const count = await query.getCount(); + return count > 0; + } + + /** + * Updates a user's username with uniqueness validation + * @param userId User ID + * @param newUsername New username to set + * @returns Updated user object + */ + async updateUsername(userId: string, newUsername: string): Promise { + if (!newUsername || newUsername.trim().length < 3) { + throw new BadRequestException( + 'Username must be at least 3 characters long', + ); + } + + // Check if the username is already taken + const exists = await this.isUsernameExists(newUsername, userId); + if (exists) { + throw new ConflictException( + `Username '${newUsername}' is already taken. Please choose another one.`, + ); + } + + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + + user.username = newUsername; + + try { + return await this.userRepository.save(user); + } catch (error) { + // Check for unique constraint error (just in case of race condition) + if (error.code === '23505' || error.message.includes('duplicate')) { + throw new ConflictException( + `Username '${newUsername}' is already taken. Please choose another one.`, + ); + } + throw error; + } + } } diff --git a/docker/project-base-image/Dockerfile b/docker/project-base-image/Dockerfile index 9d8300e0..59f9dac4 100644 --- a/docker/project-base-image/Dockerfile +++ b/docker/project-base-image/Dockerfile @@ -5,12 +5,14 @@ WORKDIR /app # Pre-install common frontend dependencies to speed up project startup RUN npm install -g npm@latest vite@latest -# Create a non-root user to run the app -RUN groupadd -r appuser && useradd -r -g appuser -m appuser -RUN chown -R appuser:appuser /app +#TODO: Uncomment this when we have a non-root usr (Allen) +# #Create a non-root user to run the app +# RUN groupadd -r appuser && useradd -r -g appuser -m appuser +# RUN chown -R appuser:appuser /app +# RUN chmod -R u+w /app -# Switch to non-root user for security -USER appuser +# # Switch to non-root user for security +# USER appuser EXPOSE 5173 diff --git a/frontend/src/app/(main)/page.tsx b/frontend/src/app/(main)/page.tsx index 60f91e01..0578d863 100644 --- a/frontend/src/app/(main)/page.tsx +++ b/frontend/src/app/(main)/page.tsx @@ -13,6 +13,7 @@ import { SignUpModal } from '@/components/sign-up-modal'; import { useRouter } from 'next/navigation'; import { logger } from '../log/logger'; import { AuroraText } from '@/components/magicui/aurora-text'; + export default function HomePage() { // States for AuthChoiceModal const [showAuthChoice, setShowAuthChoice] = useState(false); @@ -22,9 +23,10 @@ export default function HomePage() { const promptFormRef = useRef(null); const { isAuthorized } = useAuthContext(); - const { createProjectFromPrompt, isLoading } = useContext(ProjectContext); + const { createProjectFromPrompt, isLoading, setRecentlyCompletedProjectId } = + useContext(ProjectContext); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { if (!promptFormRef.current) return; const { message, isPublic, model } = promptFormRef.current.getPromptData(); @@ -34,12 +36,25 @@ export default function HomePage() { const chatId = await createProjectFromPrompt(message, isPublic, model); promptFormRef.current.clearMessage(); - router.push(`/chat?id=${chatId}`); + if (chatId) { + setRecentlyCompletedProjectId(chatId); + return chatId; + } } catch (error) { logger.error('Error creating project:', error); } }; + // useEffect(() => { + // if (!chatId) return; + + // const interval = setInterval(() => { + // pollChatProject(chatId).catch((error) => { + // logger.error('Polling error in HomePage:', error); + // }); + // }, 6000); + // return () => clearInterval(interval); + // }, [chatId, pollChatProject]); return (
diff --git a/frontend/src/app/api/screenshot/route.ts b/frontend/src/app/api/screenshot/route.ts index ee5569fd..50237cd3 100644 --- a/frontend/src/app/api/screenshot/route.ts +++ b/frontend/src/app/api/screenshot/route.ts @@ -14,6 +14,8 @@ async function getBrowser(): Promise { protocolTimeout: 240000, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); + } else { + logger.info('Reusing existing browser instance...'); } return browserInstance; } @@ -24,76 +26,118 @@ export async function GET(req: Request) { let page = null; if (!url) { + logger.warn('No URL provided in query parameters'); return NextResponse.json( { error: 'URL parameter is required' }, { status: 400 } ); } + logger.info(`[SCREENSHOT] Starting screenshot for URL: ${url}`); + try { // Get browser instance + logger.info(`[SCREENSHOT] Attempting to get browser instance`); const browser = await getBrowser(); + logger.info(`[SCREENSHOT] Browser instance acquired successfully`); // Create a new page + logger.info(`[SCREENSHOT] Creating new page`); page = await browser.newPage(); + logger.info(`[SCREENSHOT] New page created successfully`); - // Set viewport to a reasonable size - await page.setViewport({ - width: 1600, - height: 900, - }); + // Set viewport + logger.info(`[SCREENSHOT] Setting viewport`); + await page.setViewport({ width: 1600, height: 900 }); + logger.info(`[SCREENSHOT] Viewport set successfully`); // Navigate to URL with increased timeout and more reliable wait condition await page.goto(url, { - waitUntil: 'domcontentloaded', // Less strict than networkidle0 - timeout: 60000, // Increased timeout to 60 seconds + waitUntil: 'networkidle2', // 更改为等待网络空闲状态,确保页面完全加载 + timeout: 90000, // 增加超时时间到90秒 }); - await new Promise((resolve) => setTimeout(resolve, 2000)); // Waits for 2 seconds + // 等待额外的时间让页面完全渲染 + await page.waitForTimeout(8000); // 增加等待时间到8秒 + + // 尝试等待页面上的内容加载,如果失败也继续处理 + try { + // 等待页面上可能存在的主要内容元素 + await Promise.race([ + page.waitForSelector('main', { timeout: 5000 }), + page.waitForSelector('#root', { timeout: 5000 }), + page.waitForSelector('.app', { timeout: 5000 }), + page.waitForSelector('h1', { timeout: 5000 }), + page.waitForSelector('div', { timeout: 5000 }), // 添加更通用的选择器 + ]); + } catch (waitError) { + // 忽略等待选择器的错误,继续截图 + logger.info( + 'Unable to find common page elements, continuing with screenshot' + ); + } // Take screenshot + logger.info(`[SCREENSHOT] Taking screenshot`); const screenshot = await page.screenshot({ type: 'png', fullPage: true, }); + logger.info( + `[SCREENSHOT] Screenshot captured successfully, size: ${screenshot.length} bytes` + ); - // Always close the page when done + // Clean up if (page) { + logger.info(`[SCREENSHOT] Closing page`); await page.close(); + logger.info(`[SCREENSHOT] Page closed successfully`); } - // Return the screenshot as a PNG image + logger.info(`[SCREENSHOT] Returning screenshot response`); return new Response(screenshot, { headers: { 'Content-Type': 'image/png', - 'Cache-Control': 's-maxage=3600', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', }, }); } catch (error: any) { - logger.error('Screenshot error:', error); + logger.error( + `[SCREENSHOT] Error capturing screenshot: ${error.message}`, + error + ); + logger.error(`[SCREENSHOT] Error stack: ${error.stack}`); - // Ensure page is closed even if an error occurs if (page) { try { + logger.info(`[SCREENSHOT] Attempting to close page after error`); await page.close(); + logger.info(`[SCREENSHOT] Successfully closed page after error`); } catch (closeError) { - logger.error('Error closing page:', closeError); + logger.error(`[SCREENSHOT] Error closing page: ${closeError.message}`); } } - // If browser seems to be in a bad state, recreate it if ( - error.message.includes('Target closed') || - error.message.includes('Protocol error') || - error.message.includes('Target.createTarget') + error.message?.includes('Target closed') || + error.message?.includes('Protocol error') || + error.message?.includes('Target.createTarget') ) { try { if (browserInstance) { + logger.warn( + `[SCREENSHOT] Resetting browser instance due to protocol error` + ); await browserInstance.close(); browserInstance = null; + logger.warn(`[SCREENSHOT] Browser instance reset successfully`); } } catch (closeBrowserError) { - logger.error('Error closing browser:', closeBrowserError); + logger.error( + `[SCREENSHOT] Error closing browser: ${closeBrowserError.message}` + ); } } @@ -104,10 +148,10 @@ export async function GET(req: Request) { } } -// Handle process termination to close browser +// Gracefully close the browser when the process exits process.on('SIGINT', async () => { if (browserInstance) { - logger.info('Closing browser instance...'); + logger.info('SIGINT received. Closing browser instance...'); await browserInstance.close(); browserInstance = null; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0b10650f..4ee744a5 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -82,7 +82,7 @@ height: 1px; } -/* 修改滚动条颜色为主题色调 */ +/* Modify scrollbar color to match theme */ :root { --scrollbar-thumb-color: rgba( 99, diff --git a/frontend/src/app/api/media/[...path]/route.ts b/frontend/src/app/media/[...path]/route.ts similarity index 70% rename from frontend/src/app/api/media/[...path]/route.ts rename to frontend/src/app/media/[...path]/route.ts index 54619efd..857aabdb 100644 --- a/frontend/src/app/api/media/[...path]/route.ts +++ b/frontend/src/app/media/[...path]/route.ts @@ -10,15 +10,19 @@ export async function GET( ) { try { const mediaDir = getMediaDir(); + logger.info(`📁 getMediaDir = ${mediaDir}`); const filePath = path.join(mediaDir, ...params.path); const normalizedPath = path.normalize(filePath); + logger.info(`📁 getMediaDir = ${mediaDir}`); + logger.info(`📂 full filePath = ${filePath}`); + logger.debug(`Requested path: ${params.path.join('/')}`); + logger.debug(`Full resolved path: ${filePath}`); if (!normalizedPath.startsWith(mediaDir)) { - logger.error('Possible directory traversal attempt:', filePath); + logger.warn('⛔ Directory traversal attempt blocked:', filePath); return new Response('Access denied', { status: 403 }); } - // File extension allowlist const contentTypeMap: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', @@ -27,25 +31,28 @@ export async function GET( }; const ext = path.extname(filePath).toLowerCase(); + logger.debug(`File extension: ${ext}`); if (!contentTypeMap[ext]) { + logger.warn(`⛔ Forbidden file type: ${ext}`); return new Response('Forbidden file type', { status: 403 }); } - // File existence and size check let fileStat; try { fileStat = await fs.stat(filePath); } catch (err) { + logger.warn(`❌ File not found at path: ${filePath}`); return new Response('File not found', { status: 404 }); } if (fileStat.size > 10 * 1024 * 1024) { - // 10MB limit + logger.warn(`📦 File too large (${fileStat.size} bytes): ${filePath}`); return new Response('File too large', { status: 413 }); } - // Read and return the file const fileBuffer = await fs.readFile(filePath); + logger.info(`✅ Serving file: ${filePath}`); + return new Response(fileBuffer, { headers: { 'Content-Type': contentTypeMap[ext], @@ -53,8 +60,8 @@ export async function GET( 'Cache-Control': 'public, max-age=31536000', }, }); - } catch (error) { - logger.error('Error serving media file:', error); + } catch (error: any) { + logger.error('🔥 Error serving media file:', error); const errorMessage = process.env.NODE_ENV === 'development' ? `Error serving file: ${error.message}` diff --git a/frontend/src/components/avatar-uploader.tsx b/frontend/src/components/avatar-uploader.tsx index 8dae464c..0baeefeb 100644 --- a/frontend/src/components/avatar-uploader.tsx +++ b/frontend/src/components/avatar-uploader.tsx @@ -29,7 +29,7 @@ export function normalizeAvatarUrl( // Handle paths that might not have the media/ prefix if (avatarUrl.includes('avatars/')) { const parts = avatarUrl.split('avatars/'); - return `/api/media/avatars/${parts[parts.length - 1]}`; + return `/media/avatars/${parts[parts.length - 1]}`; } // Return as is for other cases diff --git a/frontend/src/components/chat-page-navigation.tsx b/frontend/src/components/chat-page-navigation.tsx new file mode 100644 index 00000000..d718daca --- /dev/null +++ b/frontend/src/components/chat-page-navigation.tsx @@ -0,0 +1,15 @@ +import { EventEnum } from '@/const/EventEnum'; +import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; + +export const redirectChatPage = ( + chatId: string, + setCurrentChatid: (id: string) => void, + setChatId: (id: string) => void, + router: AppRouterInstance +) => { + setCurrentChatid(chatId); + setChatId(chatId); + router.push(`/chat?id=${chatId}`); + const event = new Event(EventEnum.CHAT); + window.dispatchEvent(event); +}; diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 71de736d..e52e2ccb 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -10,6 +10,7 @@ import ConsoleTab from './tabs/console-tab'; import ResponsiveToolbar from './responsive-toolbar'; import SaveChangesBar from './save-changes-bar'; import { logger } from '@/app/log/logger'; +import { useAuthContext } from '@/providers/AuthProvider'; export function CodeEngine({ chatId, @@ -20,8 +21,14 @@ export function CodeEngine({ isProjectReady?: boolean; projectId?: string; }) { - const { curProject, projectLoading, pollChatProject, editorRef } = - useContext(ProjectContext); + const { + curProject, + projectLoading, + pollChatProject, + editorRef, + setRecentlyCompletedProjectId, + } = useContext(ProjectContext); + const { user } = useAuthContext(); const [localProject, setLocalProject] = useState(null); const [isLoading, setIsLoading] = useState(true); const [filePath, setFilePath] = useState(null); @@ -37,17 +44,20 @@ export function CodeEngine({ >({}); const projectPathRef = useRef(null); - const [progress, setProgress] = useState(0); // 从0%开始 - const [estimateTime, setEstimateTime] = useState(6 * 60); // 保留估计时间 + const [progress, setProgress] = useState(0); + const [estimateTime, setEstimateTime] = useState(6 * 60); const [timerActive, setTimerActive] = useState(false); - const initialTime = 6 * 60; // 初始总时间(6分钟) + const initialTime = 6 * 60; const [projectCompleted, setProjectCompleted] = useState(false); - // 添加一个状态来跟踪完成动画 const [isCompleting, setIsCompleting] = useState(false); - // 添加一个ref来持久跟踪项目状态,避免重新渲染时丢失 const isProjectLoadedRef = useRef(false); - // 在组件挂载时从localStorage检查项目是否已完成 + useEffect(() => { + if (projectCompleted) { + setRecentlyCompletedProjectId(curProject?.id || localProject?.id); + } + }, [projectCompleted]); + useEffect(() => { try { const savedCompletion = localStorage.getItem( @@ -59,16 +69,33 @@ export function CodeEngine({ setProgress(100); } } catch (e) { - // 忽略localStorage错误 + logger.error('Failed to load project completion status:', e); } }, [chatId]); - // Poll for project if needed using chatId useEffect(() => { - // 如果项目已经完成,跳过轮询 - if (projectCompleted || isProjectLoadedRef.current) { - return; + if ( + curProject?.id === chatId && + curProject?.projectPath && + !projectCompleted && + !isProjectLoadedRef.current + ) { + setProgress(100); + setTimerActive(false); + setIsCompleting(false); + setProjectCompleted(true); + isProjectLoadedRef.current = true; + + try { + localStorage.setItem(`project-completed-${chatId}`, 'true'); + } catch (e) { + logger.error('Failed to save project completion status:', e); + } } + }, [curProject?.projectPath, chatId, projectCompleted]); + + useEffect(() => { + if (projectCompleted || isProjectLoadedRef.current) return; if (!curProject && chatId && !projectLoading) { const loadProjectFromChat = async () => { @@ -76,11 +103,13 @@ export function CodeEngine({ setIsLoading(true); const project = await pollChatProject(chatId); if (project) { - setLocalProject(project); - // 如果成功加载项目,将状态设置为已完成 if (project.projectPath) { + setLocalProject(project); setProjectCompleted(true); isProjectLoadedRef.current = true; + fetchFiles(); + } else { + setLocalProject(project); } } } catch (error) { @@ -96,10 +125,8 @@ export function CodeEngine({ } }, [chatId, curProject, projectLoading, pollChatProject, projectCompleted]); - // Use either curProject from context or locally polled project const activeProject = curProject || localProject; - // Update projectPathRef when project changes useEffect(() => { if (activeProject?.projectPath) { projectPathRef.current = activeProject.projectPath; @@ -108,22 +135,16 @@ export function CodeEngine({ async function fetchFiles() { const projectPath = activeProject?.projectPath || projectPathRef.current; - if (!projectPath) { - return; - } + if (!projectPath) return; try { setIsFileStructureLoading(true); const response = await fetch(`/api/project?path=${projectPath}`); - if (!response.ok) { + if (!response.ok) throw new Error(`Failed to fetch file structure: ${response.status}`); - } const data = await response.json(); - if (data && data.res) { - setFileStructureData(data.res); - } else { - logger.warn('Empty or invalid file structure data received'); - } + if (data && data.res) setFileStructureData(data.res); + else logger.warn('Empty or invalid file structure data received'); } catch (error) { logger.error('Error fetching file structure:', error); } finally { @@ -131,7 +152,6 @@ export function CodeEngine({ } } - // Effect for loading file structure when project is ready useEffect(() => { const shouldFetchFiles = isProjectReady && @@ -139,9 +159,7 @@ export function CodeEngine({ Object.keys(fileStructureData).length === 0 && !isFileStructureLoading; - if (shouldFetchFiles) { - fetchFiles(); - } + if (shouldFetchFiles) fetchFiles(); }, [ isProjectReady, activeProject, @@ -149,7 +167,6 @@ export function CodeEngine({ fileStructureData, ]); - // Effect for selecting default file once structure is loaded useEffect(() => { if ( !isFileStructureLoading && @@ -160,10 +177,8 @@ export function CodeEngine({ } }, [isFileStructureLoading, fileStructureData, filePath]); - // Retry mechanism for fetching files if needed useEffect(() => { let retryTimeout; - if ( isProjectReady && activeProject?.projectPath && @@ -175,10 +190,7 @@ export function CodeEngine({ fetchFiles(); }, 3000); } - - return () => { - if (retryTimeout) clearTimeout(retryTimeout); - }; + return () => retryTimeout && clearTimeout(retryTimeout); }, [ isProjectReady, activeProject, @@ -197,22 +209,17 @@ export function CodeEngine({ 'index.html', 'README.md', ]; - for (const defaultFile of defaultFiles) { if (fileStructureData[`root/${defaultFile}`]) { setFilePath(defaultFile); return; } } - const firstFile = Object.entries(fileStructureData).find( ([key, item]) => key.startsWith('root/') && !item.isFolder && key !== 'root/' ); - - if (firstFile) { - setFilePath(firstFile[0].replace('root/', '')); - } + if (firstFile) setFilePath(firstFile[0].replace('root/', '')); } const handleReset = () => { @@ -224,7 +231,6 @@ export function CodeEngine({ const updateCode = async (value) => { const projectPath = activeProject?.projectPath || projectPathRef.current; if (!projectPath || !filePath) return; - try { const response = await fetch('/api/file', { method: 'POST', @@ -235,11 +241,8 @@ export function CodeEngine({ newContent: value, }), }); - - if (!response.ok) { + if (!response.ok) throw new Error(`Failed to update file: ${response.status}`); - } - await response.json(); } catch (error) { logger.error('Error updating file:', error); @@ -284,22 +287,14 @@ export function CodeEngine({ async function getCode() { const projectPath = activeProject?.projectPath || projectPathRef.current; if (!projectPath || !filePath) return; - const file_node = fileStructureData[`root/${filePath}`]; - if (!file_node) return; - - const isFolder = file_node.isFolder; - if (isFolder) return; - + if (!file_node || file_node.isFolder) return; try { const res = await fetch( `/api/file?path=${encodeURIComponent(`${projectPath}/${filePath}`)}` ); - - if (!res.ok) { + if (!res.ok) throw new Error(`Failed to fetch file content: ${res.status}`); - } - const data = await res.json(); setCode(data.content); setPrecode(data.content); @@ -307,16 +302,11 @@ export function CodeEngine({ logger.error('Error loading file content:', error); } } - getCode(); }, [filePath, activeProject, fileStructureData]); - // Determine if we're truly ready to render const showLoader = useMemo(() => { - // 如果项目已经被标记为完成,不再显示加载器 - if (projectCompleted || isProjectLoadedRef.current) { - return false; - } + if (projectCompleted || isProjectLoadedRef.current) return false; return ( !isProjectReady || isLoading || @@ -340,26 +330,26 @@ export function CodeEngine({ setTimerActive(false); setIsCompleting(false); setProjectCompleted(true); - // 同时更新ref以持久记住完成状态 isProjectLoadedRef.current = true; - - // 可选:在完成时将状态保存到localStorage try { - localStorage.setItem(`project-completed-${chatId}`, 'true'); + localStorage.setItem( + getUserStorageKey(`project-completed-${chatId}`), + 'true' + ); } catch (e) { - // 忽略localStorage错误 + logger.error('Failed to save project completion status:', e); } }, 800); }, 500); - return () => clearTimeout(completionTimer); - } else if ( + } + if ( showLoader && !timerActive && !projectCompleted && - !isProjectLoadedRef.current + !isProjectLoadedRef.current && + estimateTime > 1 ) { - // 只有在项目未被标记为完成时才重置 setTimerActive(true); setEstimateTime(initialTime); setProgress(0); @@ -369,36 +359,50 @@ export function CodeEngine({ useEffect(() => { let interval; - if (timerActive) { interval = setInterval(() => { setEstimateTime((prevTime) => { - if (prevTime <= 1) { - return initialTime; - } + if (prevTime <= 1) return 1; const elapsedTime = initialTime - prevTime + 1; - const newProgress = Math.min( - Math.floor((elapsedTime / initialTime) * 100), - 99 + setProgress( + Math.min(Math.floor((elapsedTime / initialTime) * 100), 99) ); - setProgress(newProgress); - return prevTime - 1; }); }, 1000); } - - return () => { - if (interval) clearInterval(interval); - }; + return () => interval && clearInterval(interval); }, [timerActive]); - const formatTime = (seconds) => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + // Get localStorage key with user ID + const getUserStorageKey = (key: string) => { + return user?.id ? `${key}_${user.id}` : key; }; + useEffect(() => { + if ( + curProject?.projectPath && + chatId && + projectCompleted && + !isProjectLoadedRef.current + ) { + setProgress(100); + setTimerActive(false); + setIsCompleting(false); + setProjectCompleted(true); + isProjectLoadedRef.current = true; + + try { + localStorage.setItem( + getUserStorageKey(`project-completed-${chatId}`), + 'true' + ); + } catch (e) { + logger.error('Failed to save project completion status:', e); + } + } + }, [curProject?.projectPath, chatId, projectCompleted, user?.id]); + return (
-
{(showLoader || isCompleting) && ( @@ -441,21 +444,17 @@ export function CodeEngine({ ) : ( )} -

{progress === 100 ? 'Project ready!' : projectLoading ? 'Loading project...' - : `Initializing project (${progress}%)`} + : `Preparing your project (about 5–6 minutes)…`}

-
+ {estimateTime <= 1 && !projectCompleted && ( +

+ Hang tight, almost there... +

+ )}
- - {/* 添加不同阶段的消息 */} - )}
-
{renderTabContent()}
- {saving && }
diff --git a/frontend/src/components/chat/code-engine/file-structure.tsx b/frontend/src/components/chat/code-engine/file-structure.tsx index dcddb982..1232a427 100644 --- a/frontend/src/components/chat/code-engine/file-structure.tsx +++ b/frontend/src/components/chat/code-engine/file-structure.tsx @@ -36,11 +36,11 @@ export default function FileStructure({ })) ); - // 判断是否显示加载状态 + // Check if loading state should be displayed const isEmpty = Object.keys(data).length === 0; const showLoading = isLoading || isEmpty; - // 当数据变化时更新数据提供者 + // Update data provider when data changes useEffect(() => { if (!isEmpty) { setDataProvider( @@ -52,27 +52,27 @@ export default function FileStructure({ } }, [data, isEmpty]); - // 处理选择文件事件 + // Handle file selection event const handleSelectItems = (items) => { if (items.length > 0) { const newPath = items[0].toString().replace(/^root\//, ''); const selectedItem = data[items[0]]; - // 只有当选择的是文件时才设置文件路径 + // Only set file path when a file (not folder) is selected if (selectedItem && !selectedItem.isFolder) { onFileSelect?.(newPath); } } }; - // 根据文件路径获取要展开的文件夹 + // Get expanded folders based on current file path const getExpandedFolders = () => { if (!filePath) return ['root']; const parts = filePath.split('/'); const expandedFolders = ['root']; - // 逐级构建路径 + // Build path incrementally for (let i = 0; i < parts.length - 1; i++) { const folderPath = parts.slice(0, i + 1).join('/'); expandedFolders.push(`root/${folderPath}`); @@ -100,7 +100,7 @@ export default function FileStructure({ dataProvider={dataProvider} getItemTitle={(item) => item.data} viewState={{ - // 展开包含当前文件的目录 + // Expand directories containing the current file ['fileTree']: { expandedItems: getExpandedFolders(), }, diff --git a/frontend/src/components/chat/code-engine/project-context.tsx b/frontend/src/components/chat/code-engine/project-context.tsx index 213755e2..915291f5 100644 --- a/frontend/src/components/chat/code-engine/project-context.tsx +++ b/frontend/src/components/chat/code-engine/project-context.tsx @@ -36,7 +36,7 @@ export interface ProjectContextType { prompt: string, isPublic: boolean, model?: string - ) => Promise; + ) => Promise; forkProject: (projectId: string) => Promise; setProjectPublicStatus: ( projectId: string, @@ -46,10 +46,22 @@ export interface ProjectContextType { isLoading: boolean; getWebUrl: ( projectPath: string - ) => Promise<{ domain: string; containerId: string }>; + ) => Promise<{ domain: string; containerId: string; port?: string }>; takeProjectScreenshot: (projectId: string, url: string) => Promise; refreshProjects: () => Promise; editorRef?: React.MutableRefObject; + recentlyCompletedProjectId: string | null; + setRecentlyCompletedProjectId: (id: string | null) => void; + chatId: string | null; + setChatId: (chatId: string | null) => void; + pendingProjects: Project[]; + setPendingProjects: React.Dispatch>; + refetchPublicProjects: () => Promise; + setRefetchPublicProjects: React.Dispatch< + React.SetStateAction<() => Promise> + >; + tempLoadingProjectId: string | null; + setTempLoadingProjectId: React.Dispatch>; } export const ProjectContext = createContext( @@ -98,7 +110,7 @@ const checkUrlStatus = async ( export function ProjectProvider({ children }: { children: ReactNode }) { const router = useRouter(); - const { isAuthorized } = useAuthContext(); + const { isAuthorized, user } = useAuthContext(); const [projects, setProjects] = useState([]); const [curProject, setCurProject] = useState(undefined); const [projectLoading, setProjectLoading] = useState(true); @@ -106,6 +118,78 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(false); const editorRef = useRef(null); + // Get localStorage key with user ID + const getUserStorageKey = (key: string) => { + return user?.id ? `${key}_${user.id}` : key; + }; + + const [pendingProjects, setPendingProjects] = useState(() => { + if (typeof window !== 'undefined' && user?.id) { + try { + const raw = localStorage.getItem(getUserStorageKey('pendingProjects')); + if (raw) { + return JSON.parse(raw) as Project[]; + } + } catch (e) { + logger.warn('Failed to parse pendingProjects from localStorage'); + } + } + return []; + }); + + const setRecentlyCompletedProjectId = (id: string | null) => { + if (typeof window !== 'undefined' && user?.id) { + if (id) { + localStorage.setItem(getUserStorageKey('pendingChatId'), id); + } else { + localStorage.removeItem(getUserStorageKey('pendingChatId')); + } + } + setRecentlyCompletedProjectIdRaw(id); + }; + + const [recentlyCompletedProjectIdRaw, setRecentlyCompletedProjectIdRaw] = + useState(() => + typeof window !== 'undefined' && user?.id + ? localStorage.getItem(getUserStorageKey('pendingChatId')) + : null + ); + + useEffect(() => { + if (typeof window === 'undefined' || !user?.id) return; + try { + localStorage.setItem( + getUserStorageKey('pendingProjects'), + JSON.stringify(pendingProjects) + ); + } catch (e) { + logger.warn('Failed to store pendingProjects in localStorage'); + } + }, [pendingProjects, user?.id]); + + // Setter: Update state + localStorage + const setTempLoadingProjectId = (id: string | null) => { + if (typeof window !== 'undefined' && user?.id) { + if (id) { + localStorage.setItem(getUserStorageKey('tempLoadingProjectId'), id); + } else { + localStorage.removeItem(getUserStorageKey('tempLoadingProjectId')); + } + } + setTempLoadingProjectIdRaw(id); + }; + + const [chatId, setChatId] = useState(null); + const [pollTime, setPollTime] = useState(Date.now()); + const [isCreateButtonClicked, setIsCreateButtonClicked] = useState(false); + const [tempLoadingProjectIdRaw, setTempLoadingProjectIdRaw] = useState< + string | null + >(() => { + if (typeof window !== 'undefined' && user?.id) { + return localStorage.getItem(getUserStorageKey('tempLoadingProjectId')); + } + return null; + }); interface ChatProjectCacheEntry { project: Project | null; timestamp: number; @@ -131,7 +215,9 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const MAX_RETRIES = 30; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes TTL for cache const SYNC_DEBOUNCE_TIME = 1000; // 1 second debounce for sync operations - + const [refetchPublicProjects, setRefetchPublicProjects] = useState< + () => Promise + >(() => async () => {}); // Mounted ref to prevent state updates after unmount const isMounted = useRef(true); @@ -270,6 +356,12 @@ export function ProjectProvider({ children }: { children: ReactNode }) { loadInitialData(); }, [isAuthorized]); + useEffect(() => { + const valid = pendingProjects.filter((p) => !p.projectPath); + if (valid.length !== pendingProjects.length) { + setPendingProjects(valid); + } + }, [pendingProjects]); // Initialization and update effects useEffect(() => { @@ -514,33 +606,50 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const takeProjectScreenshot = useCallback( async (projectId: string, url: string): Promise => { - // Check if this screenshot operation is already in progress const operationKey = `screenshot_${projectId}`; if (pendingOperations.current.get(operationKey)) { + logger.debug( + `[screenshot] Project ${projectId} is already being processed` + ); return; } pendingOperations.current.set(operationKey, true); + logger.debug(`[screenshot] Start for Project ${projectId}, URL: ${url}`); try { - // Check if the URL is accessible + logger.debug(`[screenshot] Checking accessibility for ${url}`); const isUrlAccessible = await checkUrlStatus(url); if (!isUrlAccessible) { - logger.warn(`URL ${url} is not accessible after multiple retries`); + logger.warn( + `[screenshot] URL ${url} is not accessible after retries` + ); return; } - // Add a cache buster to avoid previous screenshot caching const screenshotUrl = `/api/screenshot?url=${encodeURIComponent(url)}&t=${Date.now()}`; + logger.debug(`[screenshot] Sending request to ${screenshotUrl}`); const screenshotResponse = await fetch(screenshotUrl); + // 添加响应头调试 + logger.debug( + `[screenshot] Response status: ${screenshotResponse.status}` + ); + logger.debug( + `[screenshot] Response content-type: ${screenshotResponse.headers.get('content-type')}` + ); + if (!screenshotResponse.ok) { throw new Error( - `Failed to capture screenshot: ${screenshotResponse.status} ${screenshotResponse.statusText}` + `[screenshot] Failed to capture: ${screenshotResponse.status} ${screenshotResponse.statusText}` ); } const arrayBuffer = await screenshotResponse.arrayBuffer(); + logger.debug( + `[screenshot] Screenshot captured for Project ${projectId}, uploading...` + ); + const blob = new Blob([arrayBuffer], { type: 'image/png' }); const file = new File([blob], 'screenshot.png', { type: 'image/png' }); @@ -553,8 +662,9 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }, }); } catch (error) { - logger.error('Error taking screenshot:', error); + logger.error(`[screenshot] Error for Project ${projectId}:`, error); } finally { + logger.debug(`[screenshot] Finished process for Project ${projectId}`); pendingOperations.current.delete(operationKey); } }, @@ -564,7 +674,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const getWebUrl = useCallback( async ( projectPath: string - ): Promise<{ domain: string; containerId: string }> => { + ): Promise<{ domain: string; containerId: string; port?: string }> => { // Check if this operation is already in progress const operationKey = `getWebUrl_${projectPath}`; if (pendingOperations.current.get(operationKey)) { @@ -606,6 +716,13 @@ export function ProjectProvider({ children }: { children: ReactNode }) { ); } + // Extract port from domain if it's in the format domain:port + let port: string | undefined = undefined; + const domainParts = data.domain.split(':'); + if (domainParts.length > 1) { + port = domainParts[domainParts.length - 1]; + } + const baseUrl = `${URL_PROTOCOL_PREFIX}://${data.domain}`; // Find project and take screenshot if needed @@ -620,6 +737,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { return { domain: data.domain, containerId: data.containerId, + port: port, // Include port in the returned object }; } catch (error) { logger.error('Error getting web URL:', error); @@ -684,20 +802,15 @@ export function ProjectProvider({ children }: { children: ReactNode }) { prompt: string, isPublic: boolean, model = 'gpt-4o-mini' - ): Promise => { + ): Promise => { if (!prompt.trim()) { - if (isMounted.current) { - toast.error('Please enter a project description'); - } - return false; + toast.error('Please enter a project description'); + throw new Error('Invalid prompt'); } try { - if (isMounted.current) { - setIsLoading(true); - } + setIsLoading(true); - // Default packages based on typical web project needs const defaultPackages = [ { name: 'react', version: '^18.2.0' }, { name: 'next', version: '^13.4.0' }, @@ -710,25 +823,32 @@ export function ProjectProvider({ children }: { children: ReactNode }) { description: prompt, packages: defaultPackages, public: isPublic, - model: model, + model, }, }, }); - return result.data.createProject.id; - } catch (error) { - logger.error('Error creating project:', error); - if (isMounted.current) { - toast.error('Failed to create project from prompt'); + const createdChat = result.data?.createProject; + if (createdChat?.id) { + setChatId(createdChat.id); + setIsCreateButtonClicked(true); + localStorage.setItem( + getUserStorageKey('pendingChatId'), + createdChat.id + ); + setTempLoadingProjectId(createdChat.id); + return createdChat.id; + } else { + throw new Error('Project creation failed: no chatId'); } - return false; + } catch (error) { + toast.error('Failed to create project from prompt'); + throw error; } finally { - if (isMounted.current) { - setIsLoading(false); - } + setIsLoading(false); } }, - [createProject] + [createProject, setChatId, user] ); // New function to fork a project @@ -839,6 +959,30 @@ export function ProjectProvider({ children }: { children: ReactNode }) { retryCount: retries, }); + // First update the project list to ensure it exists in allProjects + setProjects((prev) => { + const exists = prev.find((p) => p.id === project.id); + return exists ? prev : [...prev, project]; + }); + + // Then more aggressively clean up pending projects + setPendingProjects((prev) => { + const filtered = prev.filter((p) => p.id !== project.id); + if (filtered.length !== prev.length) { + logger.info( + `Removed project ${project.id} from pending projects` + ); + } + return filtered; + }); + + // Then trigger the public projects refetch + await refetchPublicProjects(); + console.log( + '[pollChatProject] refetchPublicProjects triggered after project is ready:', + project.id + ); + // Trigger state sync if needed if ( now - projectSyncState.current.lastSyncTime >= @@ -851,11 +995,21 @@ export function ProjectProvider({ children }: { children: ReactNode }) { // Try to get web URL in background if (isMounted.current && project.projectPath) { - getWebUrl(project.projectPath).catch((error) => { - logger.warn('Background web URL fetch failed:', error); - }); + setCurProject(project); + localStorage.setItem('lastProjectId', project.id); + getWebUrl(project.projectPath) + .then(({ domain }) => { + const baseUrl = `${URL_PROTOCOL_PREFIX}://${domain}`; + takeProjectScreenshot(project.id, baseUrl); + }) + .catch((error) => { + logger.warn('Background web URL fetch failed:', error); + }); } + if (isMounted.current) { + setTempLoadingProjectId(null); + } return project; } } catch (error) { @@ -890,9 +1044,32 @@ export function ProjectProvider({ children }: { children: ReactNode }) { MAX_RETRIES, CACHE_TTL, SYNC_DEBOUNCE_TIME, + refetchPublicProjects, ] ); + useEffect(() => { + const interval = setInterval(() => { + setPollTime(Date.now()); // Update time every 6 seconds to trigger the useEffect below + }, 6000); + + return () => clearInterval(interval); + }, []); + + // Poll project data every 5 seconds + useEffect(() => { + if (!chatId) return; + + const fetch = async () => { + try { + await pollChatProject(chatId); + } catch (error) { + logger.error('Polling error:', error); + } + }; + + fetch(); + }, [pollTime, chatId, isCreateButtonClicked, pollChatProject]); const contextValue = useMemo( () => ({ projects, @@ -912,6 +1089,16 @@ export function ProjectProvider({ children }: { children: ReactNode }) { takeProjectScreenshot, refreshProjects, editorRef, + chatId, + setChatId, + recentlyCompletedProjectId: recentlyCompletedProjectIdRaw, + setRecentlyCompletedProjectId, + pendingProjects, + setPendingProjects, + refetchPublicProjects, + setRefetchPublicProjects, + tempLoadingProjectId: tempLoadingProjectIdRaw, + setTempLoadingProjectId, }), [ projects, @@ -928,6 +1115,15 @@ export function ProjectProvider({ children }: { children: ReactNode }) { takeProjectScreenshot, refreshProjects, editorRef, + chatId, + setChatId, + recentlyCompletedProjectIdRaw, + pendingProjects, + setPendingProjects, + refetchPublicProjects, + setRefetchPublicProjects, + tempLoadingProjectIdRaw, + setTempLoadingProjectId, ] ); diff --git a/frontend/src/components/chat/code-engine/web-view.tsx b/frontend/src/components/chat/code-engine/web-view.tsx index 81700d7f..c060c91b 100644 --- a/frontend/src/components/chat/code-engine/web-view.tsx +++ b/frontend/src/components/chat/code-engine/web-view.tsx @@ -274,11 +274,11 @@ function PreviewContent({ }; const zoomIn = () => { - setScale((prevScale) => Math.min(prevScale + 0.1, 2)); // 最大缩放比例为 2 + setScale((prevScale) => Math.min(prevScale + 0.1, 2)); // Maximum zoom scale is 2 }; const zoomOut = () => { - setScale((prevScale) => Math.max(prevScale - 0.1, 0.5)); // 最小缩放比例为 0.5 + setScale((prevScale) => Math.max(prevScale - 0.1, 0.5)); // Minimum zoom scale is 0.5 }; return ( diff --git a/frontend/src/components/chat/project-modal.tsx b/frontend/src/components/chat/project-modal.tsx index 9a5a90b9..5eea338e 100644 --- a/frontend/src/components/chat/project-modal.tsx +++ b/frontend/src/components/chat/project-modal.tsx @@ -5,8 +5,8 @@ export interface Project { id: string; projectName: string; projectPath: string; - createdAt: number; - updatedAt: number; + createdAt: string; + updatedAt: string; isActive: boolean; isDeleted: boolean; userId: string; diff --git a/frontend/src/components/global-project-poller.tsx b/frontend/src/components/global-project-poller.tsx new file mode 100644 index 00000000..0528ea69 --- /dev/null +++ b/frontend/src/components/global-project-poller.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useContext, useEffect, useRef } from 'react'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; +import { ProjectContext } from './chat/code-engine/project-context'; +import { logger } from '@/app/log/logger'; +import { ProjectReadyToast } from './project-ready-toast'; +import { URL_PROTOCOL_PREFIX } from '@/utils/const'; + +const COMPLETED_CACHE_KEY = 'completedChatIds'; + +const getCompletedFromLocalStorage = (): Set => { + try { + const raw = localStorage.getItem(COMPLETED_CACHE_KEY); + if (raw) { + return new Set(JSON.parse(raw)); + } + } catch (e) { + logger.warn('Failed to read completedChatIds from localStorage'); + } + return new Set(); +}; + +const saveCompletedToLocalStorage = (set: Set) => { + try { + localStorage.setItem(COMPLETED_CACHE_KEY, JSON.stringify(Array.from(set))); + } catch (e) { + logger.warn('Failed to save completedChatIds to localStorage'); + } +}; + +const GlobalToastListener = () => { + const { + recentlyCompletedProjectId, + setRecentlyCompletedProjectId, + pollChatProject, + setChatId, + refreshProjects, + refetchPublicProjects, + setTempLoadingProjectId, + getWebUrl, + takeProjectScreenshot, + } = useContext(ProjectContext); + const router = useRouter(); + const intervalRef = useRef(null); + const completedIdsRef = useRef>(getCompletedFromLocalStorage()); + + const setCurrentChatid = (id: string) => {}; + + useEffect(() => { + const chatId = recentlyCompletedProjectId; + if (!chatId || completedIdsRef.current.has(chatId)) return; + + intervalRef.current = setInterval(async () => { + try { + const project = await pollChatProject(chatId); + if (project?.projectPath) { + await refreshProjects(); + await refetchPublicProjects(); + setTempLoadingProjectId(null); + + // Make sure it's for project screenshot + try { + if (project.id && project.projectPath) { + logger.info( + `[PROJECT_POLLER] Taking screenshot for project ${project.id}` + ); + // Get project URL and take screenshot + const { domain, port } = await getWebUrl(project.projectPath); + + // Access directly using port + let baseUrl; + if (port) { + baseUrl = `${URL_PROTOCOL_PREFIX}://localhost:${port}`; + logger.info( + `[PROJECT_POLLER] Using localhost URL with port: ${baseUrl}` + ); + } else { + baseUrl = `${URL_PROTOCOL_PREFIX}://${domain}`; + logger.info(`[PROJECT_POLLER] Using domain URL: ${baseUrl}`); + } + + logger.info( + `[PROJECT_POLLER] Waiting for service to fully start before taking screenshot` + ); + await new Promise((resolve) => setTimeout(resolve, 10000)); // Increase wait time to 10 seconds + logger.info( + `[PROJECT_POLLER] Wait completed, proceeding with screenshot` + ); + + const result = await takeProjectScreenshot(project.id, baseUrl); + logger.info( + `[PROJECT_POLLER] Screenshot taken for project ${project.id}, result: ${JSON.stringify(result)}` + ); + } + } catch (screenshotError) { + logger.error( + `[PROJECT_POLLER] Error taking project screenshot: ${screenshotError.message}`, + screenshotError + ); + } + + toast.custom( + (t) => ( + toast.dismiss(t)} + router={router} + setCurrentChatid={setCurrentChatid} + setChatId={setChatId} + /> + ), + { duration: 10000 } + ); + + completedIdsRef.current.add(chatId); + saveCompletedToLocalStorage(completedIdsRef.current); + setRecentlyCompletedProjectId(null); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } else { + logger.debug(`Chat ${chatId} not ready yet...`); + } + } catch (e) { + logger.error('pollChatProject error:', e); + } + }, 6000); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [ + recentlyCompletedProjectId, + pollChatProject, + refreshProjects, + refetchPublicProjects, + setTempLoadingProjectId, + getWebUrl, + takeProjectScreenshot, + router, + setChatId, + ]); + + return null; +}; + +export default GlobalToastListener; diff --git a/frontend/src/components/modal.tsx b/frontend/src/components/modal.tsx index 4ba66712..04509416 100644 --- a/frontend/src/components/modal.tsx +++ b/frontend/src/components/modal.tsx @@ -19,17 +19,17 @@ export function StableModal({ children, className = '', }: ModalProps) { - // 使用ref跟踪模态框是否已挂载 + // Use ref to track if modal is mounted const modalRoot = useRef(null); - // 处理点击外部区域关闭 + // Handle clicks outside to close const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; - // 处理ESC键关闭 + // Handle ESC key to close useEffect(() => { const handleEsc = (e: KeyboardEvent) => { if (isOpen && e.key === 'Escape') { @@ -41,7 +41,7 @@ export function StableModal({ return () => window.removeEventListener('keydown', handleEsc); }, [isOpen, onClose]); - // 使用useEffect获取或创建portal容器 + // Use useEffect to get or create portal container useEffect(() => { if (typeof window !== 'undefined') { let element = document.getElementById('modal-root'); @@ -54,7 +54,7 @@ export function StableModal({ modalRoot.current = element; - // 在组件卸载时清理 + // Clean up when component unmounts return () => { if (element && element.childNodes.length === 0) { document.body.removeChild(element); @@ -63,17 +63,17 @@ export function StableModal({ } }, []); - // 在服务器端渲染时,不渲染任何内容 + // Don't render anything on server side if (typeof window === 'undefined' || !modalRoot.current) { return null; } - // 使用createPortal将模态框内容渲染到body末尾 + // Use createPortal to render modal content at the end of body return createPortal( {isOpen && (
- {/* 背景遮罩 */} + {/* Background overlay */} - {/* 模态框内容 */} + {/* Modal content */} - {/* 标题栏(如果提供) */} + {/* Title bar (if provided) */} {title && (

{title}

@@ -116,7 +116,7 @@ export function StableModal({
)} - {/* 内容区域 */} + {/* Content area */}
{children}
diff --git a/frontend/src/components/project-ready-toast.tsx b/frontend/src/components/project-ready-toast.tsx new file mode 100644 index 00000000..f16243dd --- /dev/null +++ b/frontend/src/components/project-ready-toast.tsx @@ -0,0 +1,79 @@ +'use client'; +import { motion } from 'framer-motion'; +import { X } from 'lucide-react'; +import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import { redirectChatPage } from './chat-page-navigation'; + +interface ProjectReadyToastProps { + chatId: string; + close: () => void; + router: AppRouterInstance; + setCurrentChatid: (id: string) => void; + setChatId: (id: string) => void; +} + +export const ProjectReadyToast = ({ + chatId, + close, + router, + setCurrentChatid, + setChatId, +}: ProjectReadyToastProps) => { + return ( + + {/* Left: Icon + Text */} +
+
+ + + +
+ Project is ready! +
+ + {/* Right: Open Chat + Close */} +
+ + +
+
+ ); +}; diff --git a/frontend/src/components/root/expand-card.tsx b/frontend/src/components/root/expand-card.tsx index 0ca16999..f2b040ab 100644 --- a/frontend/src/components/root/expand-card.tsx +++ b/frontend/src/components/root/expand-card.tsx @@ -1,4 +1,5 @@ 'use client'; + import Image from 'next/image'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; @@ -6,12 +7,18 @@ import { X } from 'lucide-react'; import { ProjectContext } from '../chat/code-engine/project-context'; import { URL_PROTOCOL_PREFIX } from '@/utils/const'; import { logger } from '@/app/log/logger'; +import { Button } from '@/components/ui/button'; -export function ExpandableCard({ projects }) { +export function ExpandableCard({ + projects, + isGenerating = false, + onOpenChat, + isCommunityProject = false, +}) { const [active, setActive] = useState(null); const [iframeUrl, setIframeUrl] = useState(''); const ref = useRef(null); - const { getWebUrl, takeProjectScreenshot } = useContext(ProjectContext); + const { getWebUrl } = useContext(ProjectContext); const cachedUrls = useRef(new Map()); useEffect(() => { @@ -29,23 +36,15 @@ export function ExpandableCard({ projects }) { window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [active]); - const handleCardClick = async (project) => { - setActive(project); - setIframeUrl(''); - if (cachedUrls.current.has(project.id)) { - setIframeUrl(cachedUrls.current.get(project.id)); - return; - } - try { - const data = await getWebUrl(project.path); - const url = `${URL_PROTOCOL_PREFIX}://${data.domain}`; - cachedUrls.current.set(project.id, url); - setIframeUrl(url); - } catch (error) { - logger.error('Error fetching project URL:', error); + const handleCardClick = async (project) => { + if (isCommunityProject) { + setActive(project); + } else if (onOpenChat) { + onOpenChat(); } }; + return ( <> @@ -55,10 +54,7 @@ export function ExpandableCard({ projects }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - transition={{ - duration: 0.3, - ease: [0.4, 0, 0.2, 1], - }} + transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }} className="fixed inset-0 backdrop-blur-[2px] bg-black/20 h-full w-full z-50" style={{ willChange: 'opacity' }} /> @@ -113,20 +109,12 @@ export function ExpandableCard({ projects }) { ) : null} -
+
{projects.map((project) => ( { - const data = await getWebUrl(project.path); - - logger.info(project.image); - const url = `${URL_PROTOCOL_PREFIX}://${data.domain}`; - setIframeUrl(url); - handleCardClick(project); - setActive(project); - }} + onClick={() => handleCardClick(project)} className="group cursor-pointer" > {project.name} - View Project + {isGenerating ? 'Open Chat' : 'Open Project'} - + {project.name} {project.author} + {isGenerating && onOpenChat && ( +
+ +
+ )}
))} diff --git a/frontend/src/components/root/projects-section.tsx b/frontend/src/components/root/projects-section.tsx index 9a06e5a2..f8c0a709 100644 --- a/frontend/src/components/root/projects-section.tsx +++ b/frontend/src/components/root/projects-section.tsx @@ -1,58 +1,240 @@ +'use client'; + import { useQuery } from '@apollo/client'; -import { FETCH_PUBLIC_PROJECTS } from '@/graphql/request'; +import { FETCH_PUBLIC_PROJECTS, GET_USER_PROJECTS } from '@/graphql/request'; import { ExpandableCard } from './expand-card'; +import { useContext, useEffect, useMemo, useState } from 'react'; +import { ProjectContext } from '../chat/code-engine/project-context'; +import { redirectChatPage } from '../chat-page-navigation'; +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; +import { useAuthContext } from '@/providers/AuthProvider'; +import { Project } from '../chat/project-modal'; export function ProjectsSection() { - // Execute the GraphQL query with provided variables - const { data, loading, error } = useQuery(FETCH_PUBLIC_PROJECTS, { - // Make sure strategy matches the backend definition (e.g., 'latest' or 'trending') - variables: { input: { size: 10, strategy: 'latest' } }, + const [view, setView] = useState<'my' | 'community'>('my'); + const { user } = useAuthContext(); + const router = useRouter(); + + const { + setChatId, + pendingProjects, + setPendingProjects, + setRefetchPublicProjects, + tempLoadingProjectId, + } = useContext(ProjectContext); + + const [currentChatid, setCurrentChatid] = useState(''); + const [publicRefreshCounter, setPublicRefreshCounter] = useState(0); + + // Fetch both public and user projects + const { + data: publicData, + loading: publicLoading, + error: publicError, + refetch: refetchPublic, + } = useQuery(FETCH_PUBLIC_PROJECTS, { + variables: { + input: { size: 100, strategy: 'latest', currentUserId: user?.id || '' }, + }, + fetchPolicy: 'network-only', }); - const fetchedProjects = data?.fetchPublicProjects || []; - - // Transform fetched data to match the component's expected format - const transformedProjects = fetchedProjects.map((project) => ({ - id: project.id, - name: project.projectName, - path: project.projectPath, - createDate: project.createdAt - ? new Date(project.createdAt).toISOString().split('T')[0] - : '2025-01-01', - author: project.user?.username || 'Unknown', - forkNum: project.subNumber || 0, - image: - project.photoUrl || `https://picsum.photos/500/250?random=${project.id}`, - })); + const { + data: userData, + loading: userLoading, + error: userError, + refetch: refetchUser, + } = useQuery(GET_USER_PROJECTS, { + fetchPolicy: 'network-only', + }); + + const publicProjects = publicData?.fetchPublicProjects || []; + const userProjects = userData?.getUserProjects || []; + + // Add effect to listen for project deletion + useEffect(() => { + const handleProjectDelete = () => { + refetchUser(); + refetchPublic(); + // Clean up any deleted projects from pendingProjects + setPendingProjects((prev) => + prev.filter((p) => userProjects.some((up) => up.id === p.id)) + ); + }; + + window.addEventListener('project-deleted', handleProjectDelete); + return () => { + window.removeEventListener('project-deleted', handleProjectDelete); + }; + }, [refetchUser, refetchPublic, setPendingProjects, userProjects]); + + useEffect(() => { + setRefetchPublicProjects(() => async () => { + setPublicRefreshCounter((prev) => prev + 1); + await refetchPublic(); + return await refetchUser(); + }); + }, [refetchPublic, refetchUser, setRefetchPublicProjects]); + + useEffect(() => { + refetchPublic(); + refetchUser(); + }, [publicRefreshCounter]); + + useEffect(() => { + const realMap = new Map(userProjects.map((p: Project) => [p.id, p])); + + setPendingProjects((prev) => { + const next = prev.filter((p) => { + const real = realMap.get(p.id) as Project | undefined; + return !real || !real.projectPath; + }); + return next.length === prev.length ? prev : next; + }); + }, [userProjects, setPendingProjects]); + + const mergedMyProjects = useMemo(() => { + const map = new Map(); + + // Only add pending projects that are not in userProjects (not yet completed) + pendingProjects + .filter((p) => !userProjects.some((up) => up.id === p.id)) + .forEach((p) => + map.set(p.id, { + ...p, + userId: String(p.userId ?? user?.id), + createdAt: p.createdAt || new Date().toISOString(), + }) + ); + + // Add all user projects + userProjects.forEach((p) => map.set(p.id, p)); + + return Array.from(map.values()); + }, [pendingProjects, userProjects, user?.id]); + + const displayProjects = view === 'my' ? mergedMyProjects : publicProjects; + + const transformedProjects = [...displayProjects] + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + .map((project) => ({ + id: project.id, + name: project.projectName || 'Untitled Project', + path: project.projectPath ?? '', + isReady: !!project.projectPath, + createDate: project.createdAt + ? new Date(project.createdAt).toISOString().split('T')[0] + : 'N/A', + author: project.user?.username || user?.username || 'Unknown', + forkNum: project.subNumber || 0, + image: project.photoUrl || null, + })); + + // Add temporary generating projects + const allProjects = [...transformedProjects]; + + // Add currently loading project (if exists and not already in the list) + if ( + view === 'my' && + tempLoadingProjectId && + !allProjects.some((p) => p.id === tempLoadingProjectId) + ) { + allProjects.unshift({ + id: tempLoadingProjectId, + name: 'Generating Project...', + path: '', + isReady: false, + createDate: new Date().toISOString().split('T')[0], + author: user?.username || 'Unknown', + forkNum: 0, + image: null, + }); + } + + // Add other pending projects + if (view === 'my') { + pendingProjects + .filter( + (p) => + !p.projectPath && + p.id !== tempLoadingProjectId && + !allProjects.some((proj) => proj.id === p.id) + ) + .forEach((project) => { + allProjects.unshift({ + id: project.id, + name: project.projectName || 'Generating Project...', + path: '', + isReady: false, + createDate: + project.createdAt || new Date().toISOString().split('T')[0], + author: user?.username || 'Unknown', + forkNum: 0, + image: null, + }); + }); + } + + const handleOpenChat = (chatId: string) => { + redirectChatPage(chatId, setCurrentChatid, setChatId, router); + }; + + const loading = view === 'my' ? userLoading : publicLoading; + const error = view === 'my' ? userError : publicError; return (
- {/* Header and "View All" button always visible */}

- Featured Projects + {view === 'my' ? 'My Projects' : 'Community Projects'}

- +
+ + +
{loading ? (
Loading...
) : error ? ( -
Error: {error.message}
+
+ Error: {error.message} +
) : ( -
- {transformedProjects.length > 0 ? ( - + <> + {allProjects.length > 0 ? ( +
+ {allProjects.map((project) => ( + handleOpenChat(project.id)} + isCommunityProject={view === 'community'} + /> + ))} +
) : ( - // Show message when no projects are available
No projects available.
)} -
+ )}
diff --git a/frontend/src/components/root/prompt-form.tsx b/frontend/src/components/root/prompt-form.tsx index 68a9c1c2..73c31c0f 100644 --- a/frontend/src/components/root/prompt-form.tsx +++ b/frontend/src/components/root/prompt-form.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useState, forwardRef, useImperativeHandle, useEffect } from 'react'; +import { + useState, + forwardRef, + useImperativeHandle, + useEffect, + useContext, + useCallback, +} from 'react'; import { SendIcon, Sparkles, Globe, Lock, Loader2, Cpu } from 'lucide-react'; import Typewriter from 'typewriter-effect'; import { @@ -22,6 +29,10 @@ import { useModels } from '@/hooks/useModels'; import { gql, useMutation } from '@apollo/client'; import { logger } from '@/app/log/logger'; +import { useRouter } from 'next/navigation'; +import { ProjectContext } from '../chat/code-engine/project-context'; +import { redirectChatPage } from '../chat-page-navigation'; + export interface PromptFormRef { getPromptData: () => { message: string; @@ -33,7 +44,8 @@ export interface PromptFormRef { interface PromptFormProps { isAuthorized: boolean; - onSubmit: () => void; + onSubmit: () => Promise; + // onChatCreated: (chatId: string) => void; onAuthRequired: () => void; isLoading?: boolean; } @@ -54,9 +66,12 @@ export const PromptForm = forwardRef( 'public' ); const [isEnhanced, setIsEnhanced] = useState(false); - const [isFocused, setIsFocused] = useState(false); // 追踪 textarea focus + const [isFocused, setIsFocused] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); - + const router = useRouter(); + const [currentChatid, setCurrentChatid] = useState(''); + const { setChatId, setRecentlyCompletedProjectId } = + useContext(ProjectContext); const { selectedModel, setSelectedModel, @@ -79,14 +94,26 @@ export const PromptForm = forwardRef( } ); - const handleSubmit = () => { + const handleSubmit = useCallback(async () => { if (isLoading || isRegenerating) return; if (!isAuthorized) { onAuthRequired(); } else { - onSubmit(); + const chatId = await onSubmit(); + if (chatId) { + setRecentlyCompletedProjectId(chatId); + redirectChatPage(chatId, setCurrentChatid, setChatId, router); + } } - }; + }, [ + isAuthorized, + isLoading, + isRegenerating, + onAuthRequired, + onSubmit, + setChatId, + router, + ]); const handleMagicEnhance = () => { if (isLoading || isRegenerating) return; diff --git a/frontend/src/components/sidebar-item.tsx b/frontend/src/components/sidebar-item.tsx index 45d0e14f..375b139a 100644 --- a/frontend/src/components/sidebar-item.tsx +++ b/frontend/src/components/sidebar-item.tsx @@ -14,15 +14,21 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { DELETE_CHAT } from '@/graphql/request'; +import { + DELETE_CHAT, + DELETE_PROJECT, + GET_CHAT_DETAILS, +} from '@/graphql/request'; import { cn } from '@/lib/utils'; -import { useMutation } from '@apollo/client'; +import { useMutation, useLazyQuery } from '@apollo/client'; import { MoreHorizontal, Trash2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { memo, useState } from 'react'; +import { memo, useContext, useState } from 'react'; import { toast } from 'sonner'; import { EventEnum } from '../const/EventEnum'; import { logger } from '@/app/log/logger'; +import { ProjectContext } from './chat/code-engine/project-context'; +import { motion } from 'framer-motion'; interface SideBarItemProps { id: string; @@ -42,18 +48,36 @@ function SideBarItemComponent({ const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const router = useRouter(); - + const { recentlyCompletedProjectId, setPendingProjects } = + useContext(ProjectContext); + const isGenerating = id === recentlyCompletedProjectId; const isSelected = currentChatId === id; const variant = isSelected ? 'secondary' : 'ghost'; + const [getChatDetails] = useLazyQuery(GET_CHAT_DETAILS); + + const [deleteProject] = useMutation(DELETE_PROJECT, { + onCompleted: () => { + logger.info('Project deleted successfully'); + }, + onError: (error) => { + logger.error('Error deleting project:', error); + toast.error('Failed to delete associated project'); + }, + }); + const [deleteChat] = useMutation(DELETE_CHAT, { onCompleted: () => { - toast.success('Chat deleted successfully'); + toast.success('Chat and associated project deleted successfully'); if (isSelected) { router.push('/'); const event = new Event(EventEnum.NEW_CHAT); window.dispatchEvent(event); } + // Remove from pendingProjects + setPendingProjects((prev) => prev.filter((p) => p.id !== id)); + // Dispatch project-deleted event + window.dispatchEvent(new Event('project-deleted')); refetchChats(); }, onError: (error) => { @@ -64,11 +88,38 @@ function SideBarItemComponent({ const handleDeleteChat = async () => { try { + const chatDetailsResult = await getChatDetails({ + variables: { chatId: id }, + }); + + const projectId = chatDetailsResult?.data?.getChatDetails?.project?.id; + await deleteChat({ variables: { chatId: id, }, + update: (cache) => { + // Remove the deleted chat from Apollo cache + cache.evict({ id: `Chat:${id}` }); + cache.gc(); + }, }); + + if (projectId) { + try { + await deleteProject({ + variables: { projectId }, + update: (cache) => { + // Clear project cache + cache.evict({ id: `Project:${projectId}` }); + cache.gc(); + }, + }); + } catch (projectError) { + logger.error('Error deleting associated project:', projectError); + } + } + setIsDialogOpen(false); } catch (error) { logger.error('Error deleting chat:', error); @@ -83,83 +134,97 @@ function SideBarItemComponent({ }; return ( -
- - - - - - - { - setIsDropdownOpen(false); - setIsDialogOpen(true); - }} - > + + + - - - - - - Delete chat? - - Are you sure you want to delete this chat? This action cannot be - undone. - -
- - -
-
-
-
- + + + + + + + Delete chat? + + Are you sure you want to delete this chat? This action cannot be + undone. + +
+ + +
+
+
+ + + ); } diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 620570ea..876abbe5 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -26,9 +26,8 @@ import { motion } from 'framer-motion'; import { logger } from '@/app/log/logger'; import { useChatList } from '@/hooks/useChatList'; import { cn } from '@/lib/utils'; -import { PlusIcon } from 'lucide-react'; import { HomeIcon } from '@radix-ui/react-icons'; - +import { redirectChatPage } from './chat-page-navigation'; interface SidebarProps { setIsModalOpen: (value: boolean) => void; isCollapsed: boolean; @@ -96,20 +95,13 @@ function ChatSideBarComponent({ }: SidebarProps) { const router = useRouter(); const [currentChatid, setCurrentChatid] = useState(''); - const { setCurProject, pollChatProject } = useContext(ProjectContext); + const { setChatId } = useContext(ProjectContext); const handleChatSelect = useCallback( (chatId: string) => { - setCurrentChatid(chatId); - router.push(`/chat?id=${chatId}`); - setCurProject(null); - pollChatProject(chatId).then((p) => { - setCurProject(p); - }); - const event = new Event(EventEnum.CHAT); - window.dispatchEvent(event); + redirectChatPage(chatId, setCurrentChatid, setChatId, router); }, - [router, setCurProject, pollChatProject] + [router, setChatId] ); if (loading) return ; diff --git a/frontend/src/graphql/request.ts b/frontend/src/graphql/request.ts index 85659e2a..0ca970bf 100644 --- a/frontend/src/graphql/request.ts +++ b/frontend/src/graphql/request.ts @@ -24,6 +24,7 @@ export const FETCH_PUBLIC_PROJECTS = gql` projectName projectPath createdAt + userId user { username } @@ -99,6 +100,7 @@ export const DELETE_CHAT = gql` export const GET_USER_INFO = gql` query me { me { + id username email avatarUrl @@ -126,6 +128,7 @@ export const GET_USER_PROJECTS = gql` userId forkedFromId isDeleted + createdAt projectPackages { id content @@ -204,6 +207,16 @@ export const CREATE_PROJECT = gql` photoUrl userId subNumber + createdAt + updatedAt + isActive + isDeleted + projectPackages { + id + name + version + content + } } } } @@ -235,7 +248,6 @@ export const UPDATE_PROJECT_PUBLIC_STATUS = gql` updateProjectPublicStatus(projectId: $projectId, isPublic: $isPublic) { id projectName - path projectPackages { id content @@ -254,6 +266,13 @@ export const UPDATE_PROJECT_PHOTO_URL = gql` } `; +// Mutation to delete a project +export const DELETE_PROJECT = gql` + mutation DeleteProject($projectId: String!) { + deleteProject(projectId: $projectId) + } +`; + // Query to get subscribed/forked projects export const GET_SUBSCRIBED_PROJECTS = gql` query GetSubscribedProjects { diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 8bbdde17..78bbeb72 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -69,6 +69,7 @@ type EmailConfirmationResponse { } input FetchPublicProjectsInputs { + currentUserId: String! size: Float! strategy: String! } diff --git a/frontend/src/hooks/useChatList.ts b/frontend/src/hooks/useChatList.ts index 31245a60..f2a28517 100644 --- a/frontend/src/hooks/useChatList.ts +++ b/frontend/src/hooks/useChatList.ts @@ -1,12 +1,13 @@ import { useQuery } from '@apollo/client'; import { GET_USER_CHATS } from '@/graphql/request'; import { Chat } from '@/graphql/type'; -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; import { useAuthContext } from '@/providers/AuthProvider'; +import { EventEnum } from '@/const/EventEnum'; export function useChatList() { const [chatListUpdated, setChatListUpdated] = useState(false); - const { isAuthorized } = useAuthContext(); + const { isAuthorized, user } = useAuthContext(); const { data: chatData, loading, @@ -25,12 +26,31 @@ export function useChatList() { setChatListUpdated(value); }, []); + // Listen for user changes and new chat events + useEffect(() => { + const handleNewChat = () => { + handleRefetch(); + }; + + window.addEventListener(EventEnum.NEW_CHAT, handleNewChat); + return () => { + window.removeEventListener(EventEnum.NEW_CHAT, handleNewChat); + }; + }, [handleRefetch]); + + // When the user ID changes, force refresh the chat list + useEffect(() => { + if (user?.id) { + handleRefetch(); + } + }, [user?.id, handleRefetch]); + const sortedChats = useMemo(() => { const chats = chatData?.getUserChats || []; - return [...chats].sort( - (a: Chat, b: Chat) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); + // Sort chats by createdAt in descending order (newest first) + return [...chats].sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); }, [chatData?.getUserChats]); return { diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index 6c8e4572..8faba93c 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -139,6 +139,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const logout = useCallback(() => { + // Clear current user data + if (user?.id) { + // Clear all localStorage data related to the current user + localStorage.removeItem(`completedChatIds_${user.id}`); + localStorage.removeItem(`pendingChatId_${user.id}`); + localStorage.removeItem(`pendingProjects_${user.id}`); + localStorage.removeItem(`tempLoadingProjectId_${user.id}`); + localStorage.removeItem(`lastProjectId_${user.id}`); + + // Clear all project completion status for the current user + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(`project-completed-${user.id}-`)) { + localStorage.removeItem(key); + } + } + } + + // Clear authentication related localStorage setToken(null); setIsAuthorized(false); setUser(null); diff --git a/frontend/src/providers/BaseProvider.tsx b/frontend/src/providers/BaseProvider.tsx index b5e928bd..a7ad57d9 100644 --- a/frontend/src/providers/BaseProvider.tsx +++ b/frontend/src/providers/BaseProvider.tsx @@ -4,11 +4,8 @@ import dynamic from 'next/dynamic'; import { ThemeProvider } from 'next-themes'; import { Toaster } from 'sonner'; import { AuthProvider } from './AuthProvider'; -import { - ProjectContext, - ProjectProvider, -} from '@/components/chat/code-engine/project-context'; - +import { ProjectProvider } from '@/components/chat/code-engine/project-context'; +import GlobalToastListener from '@/components/global-project-poller'; const DynamicApolloProvider = dynamic(() => import('./DynamicApolloProvider'), { ssr: false, // disables SSR for the ApolloProvider }); @@ -23,6 +20,7 @@ export function BaseProviders({ children }: ProvidersProps) { + {children}