Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend): adding project screenshot #159

Merged
merged 5 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions backend/src/upload/upload.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class UploadService {
// Get the appropriate URL for the uploaded file
const bucketUrl = this.getBucketUrl();

return { url: `${bucketUrl}/${key}`, key };
return { url: path.join(bucketUrl, key), key };
} else {
// Upload to local storage from buffer
const directory = path.join(this.mediaDir, subdirectory);
Expand Down Expand Up @@ -135,8 +135,7 @@ export class UploadService {

// Get the appropriate URL for the uploaded file
const bucketUrl = this.getBucketUrl();

return { url: `${bucketUrl}/${key}`, key };
return { url: path.join(bucketUrl, key), key };
} else {
// Upload to local storage using stream
const directory = path.join(this.mediaDir, subdirectory);
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"motion": "^12.4.7",
"next": "^14.2.13",
"next-themes": "^0.3.0",
"puppeteer": "^24.3.1",
"react": "^18.3.1",
"react-activity-calendar": "^2.7.8",
"react-code-blocks": "^0.1.6",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/app/api/runProject/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { exec } from 'child_process';
import * as path from 'path';
import * as net from 'net';
import { getProjectPath } from 'codefox-common';
import puppetter from 'puppeteer';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix typo in puppeteer import.

There's a typo in the puppeteer import that could cause runtime errors.

-import puppetter from 'puppeteer';
+import puppeteer from 'puppeteer';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import puppetter from 'puppeteer';
import puppeteer from 'puppeteer';

import { useMutation } from '@apollo/client/react/hooks/useMutation';
import { toast } from 'sonner';
import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request';
Comment on lines +7 to +9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unused imports.

These imports are not used in this file and should be removed. In particular, useMutation is a client-side React hook that shouldn't be imported in a server-side API route.

-import { useMutation } from '@apollo/client/react/hooks/useMutation';
-import { toast } from 'sonner';
-import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useMutation } from '@apollo/client/react/hooks/useMutation';
import { toast } from 'sonner';
import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request';


const runningContainers = new Map<
string,
Expand Down Expand Up @@ -294,6 +298,7 @@ export async function GET(req: Request) {

try {
const { domain, containerId } = await buildAndRunDocker(projectPath);

return NextResponse.json({
message: 'Docker container started',
domain,
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/app/api/screenshot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { randomUUID } from 'crypto';
import { NextResponse } from 'next/server';
import puppeteer from 'puppeteer';

export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const url = searchParams.get('url');

if (!url) {
return NextResponse.json(
{ error: 'URL parameter is required' },
{ status: 400 }
);
}

try {
const browser = await puppeteer.launch({
headless: true,
});
const page = await browser.newPage();

// Set viewport to a reasonable size
await page.setViewport({
width: 1280,
height: 720,
});

await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000,
});

// Take screenshot
const screenshot = await page.screenshot({
path: `dsadas.png`,
type: 'png',
fullPage: true,
});

await browser.close();

// Return the screenshot as a PNG image
return new Response(screenshot, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 's-maxage=3600',
},
});
} catch (error: any) {
console.error('Screenshot error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to capture screenshot' },
{ status: 500 }
);
}
}
154 changes: 150 additions & 4 deletions frontend/src/components/chat/code-engine/project-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
GET_CHAT_DETAILS,
GET_USER_PROJECTS,
UPDATE_PROJECT_PUBLIC_STATUS,
UPDATE_PROJECT_PHOTO_URL,
} from '@/graphql/request';
import { Project } from '../project-modal';
import { useRouter } from 'next/navigation';
Expand All @@ -40,12 +41,31 @@ export interface ProjectContextType {
) => Promise<void>;
pollChatProject: (chatId: string) => Promise<Project | null>;
isLoading: boolean;
getWebUrl: (
projectPath: string
) => Promise<{ domain: string; containerId: string }>;
takeProjectScreenshot: (projectId: string, url: string) => Promise<void>;
}

export const ProjectContext = createContext<ProjectContextType | undefined>(
undefined
);

const checkUrlStatus = async (url: string) => {
let status = 0;
while (status !== 200) {
try {
const res = await fetch(url, { method: 'HEAD' });
status = res.status;
if (status !== 200) {
console.log(`URL status: ${status}. Retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} catch (err) {
console.error('Error checking URL status:', err);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
};
export function ProjectProvider({ children }: { children: ReactNode }) {
const router = useRouter();

Expand Down Expand Up @@ -173,6 +193,106 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
}
);

const [updateProjectPhotoMutation] = useMutation(UPDATE_PROJECT_PHOTO_URL, {
onCompleted: (data) => {
// Update projects list
setProjects((prev) =>
prev.map((project) =>
project.id === data.updateProjectPhoto.id
? {
...project,
photoUrl: data.updateProjectPhoto.photoUrl,
}
: project
)
);

// Update current project if it's the one being modified
if (curProject?.id === data.updateProjectPhoto.id) {
setCurProject((prev) =>
prev
? {
...prev,
photoUrl: data.updateProjectPhoto.photoUrl,
}
: prev
);
}
},
onError: (error) => {
toast.error(`Failed to update project photo: ${error.message}`);
},
});

const takeProjectScreenshot = useCallback(
async (projectId: string, url: string) => {
try {
await checkUrlStatus(url);

const screenshotResponse = await fetch(
`/api/screenshot?url=${encodeURIComponent(url)}`
);

if (!screenshotResponse.ok) {
throw new Error('Failed to capture screenshot');
}

const arrayBuffer = await screenshotResponse.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'image/png' });

const file = new File([blob], 'screenshot.png', { type: 'image/png' });

await updateProjectPhotoMutation({
variables: {
input: {
projectId,
file,
},
},
});
} catch (error) {
console.error('Error:', error);
}
},
[updateProjectPhotoMutation]
);

const getWebUrl = useCallback(
async (projectPath: string) => {
try {
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);

if (!response.ok) {
throw new Error('Failed to get web URL');
}

const data = await response.json();
const baseUrl = `http://${data.domain}`;
const project = projects.find((p) => p.projectPath === projectPath);
if (project) {
await takeProjectScreenshot(project.id, baseUrl);
}

return {
domain: data.domain,
containerId: data.containerId,
};
} catch (error) {
console.error('Error getting web URL:', error);
throw error;
}
},
[projects, updateProjectPhotoMutation]
);

const [getChatDetail] = useLazyQuery(GET_CHAT_DETAILS, {
fetchPolicy: 'network-only',
});
Expand Down Expand Up @@ -300,8 +420,31 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
const { data } = await getChatDetail({ variables: { chatId } });

if (data?.getChatDetails?.project) {
chatProjectCache.current.set(chatId, data.getChatDetails.project);
return data.getChatDetails.project;
const project = data.getChatDetails.project;
chatProjectCache.current.set(chatId, project);

try {
// Get web URL and wait for it to be ready
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(project.projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);

if (response.ok) {
const data = await response.json();
const baseUrl = `http://${data.domain}`;
await takeProjectScreenshot(project.id, baseUrl);
}
} catch (error) {
console.error('Error capturing project screenshot:', error);
}

return project;
}
} catch (error) {
console.error('Error polling chat:', error);
Expand All @@ -314,7 +457,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
chatProjectCache.current.set(chatId, null);
return null;
},
[getChatDetail]
[getChatDetail, updateProjectPhotoMutation]
);

const contextValue = useMemo(
Expand All @@ -331,6 +474,8 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
setProjectPublicStatus,
pollChatProject,
isLoading,
getWebUrl,
takeProjectScreenshot,
}),
[
projects,
Expand All @@ -342,6 +487,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
setProjectPublicStatus,
pollChatProject,
isLoading,
getWebUrl,
]
);

Expand Down
52 changes: 13 additions & 39 deletions frontend/src/components/chat/code-engine/web-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import {
ZoomIn,
ZoomOut,
} from 'lucide-react';
import puppeteer from 'puppeteer';

export default function WebPreview() {
const { curProject } = useContext(ProjectContext);
const { curProject, getWebUrl } = useContext(ProjectContext);
if (!curProject || !getWebUrl) {
throw new Error('ProjectContext not properly initialized');
}
const [baseUrl, setBaseUrl] = useState('');
const [displayPath, setDisplayPath] = useState('/');
const [history, setHistory] = useState<string[]>(['/']);
Expand All @@ -26,7 +30,7 @@ export default function WebPreview() {
const lastProjectPathRef = useRef<string | null>(null);

useEffect(() => {
const getWebUrl = async () => {
const initWebUrl = async () => {
if (!curProject) return;
const projectPath = curProject.projectPath;

Expand All @@ -42,53 +46,23 @@ export default function WebPreview() {
}

try {
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const json = await response.json();

await new Promise((resolve) => setTimeout(resolve, 100));

const { domain } = await getWebUrl(projectPath);
containerRef.current = {
projectPath,
domain: json.domain,
domain,
};

const checkUrlStatus = async (url: string) => {
let status = 0;
while (status !== 200) {
try {
const res = await fetch(url, { method: 'HEAD' });
status = res.status;
if (status !== 200) {
console.log(`URL status: ${status}. Retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} catch (err) {
console.error('Error checking URL status:', err);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
};

const baseUrl = `http://${json.domain}`;
await checkUrlStatus(baseUrl);

const baseUrl = `http://${domain}`;
console.log('baseUrl:', baseUrl);
setBaseUrl(baseUrl);
setDisplayPath('/');
} catch (error) {
console.error('fetching url error:', error);
console.error('Error getting web URL:', error);
}
};

getWebUrl();
}, [curProject]);
initWebUrl();
}, [curProject, getWebUrl]);

useEffect(() => {
if (iframeRef.current && baseUrl) {
Expand Down
Loading
Loading