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): implement expandable project cards and fetch public p… #152

Merged
merged 8 commits into from
Mar 5, 2025
173 changes: 173 additions & 0 deletions frontend/src/components/root/expand-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
'use client';
import Image from 'next/image';
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from 'lucide-react';

export function ExpandableCard({ projects }) {
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

Add TypeScript interface for projects prop

The component accepts a projects prop without type definition, which reduces type safety and could lead to runtime errors.

-export function ExpandableCard({ projects }) {
+interface Project {
+  id: string;
+  name: string;
+  path: string;
+  createDate: string;
+  author: string;
+  forkNum: number;
+  image: string;
+}
+
+interface ExpandableCardProps {
+  projects: Project[];
+}
+
+export function ExpandableCard({ projects }: ExpandableCardProps) {
📝 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
export function ExpandableCard({ projects }) {
interface Project {
id: string;
name: string;
path: string;
createDate: string;
author: string;
forkNum: number;
image: string;
}
interface ExpandableCardProps {
projects: Project[];
}
export function ExpandableCard({ projects }: ExpandableCardProps) {

const [active, setActive] = useState(null);
const [iframeUrl, setIframeUrl] = useState('');
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setActive(null);
}
}

if (active && typeof active === 'object') {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}

window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [active]);

const getWebUrl = async (project) => {
if (!project) return;
console.log('project:', project);
const projectPath = project.path;

try {
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const json = await response.json();
const baseUrl = `http://${json.domain}`;
setIframeUrl(baseUrl);
setActive(project);
} catch (error) {
console.error('fetching url error:', error);
}
};
Comment on lines +34 to +51
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

Improve error handling and add loading state for URL fetching

The current implementation only logs errors to the console without providing user feedback. Add proper error handling and loading state to improve the user experience.

+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);

  const getWebUrl = async (project) => {
    if (!project) return;
-    console.log('project:', project);
    const projectPath = project.path;

+    setIsLoading(true);
+    setError(null);
    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 fetch project URL (${response.status})`);
+      }
      const json = await response.json();
      const baseUrl = `http://${json.domain}`;
      setIframeUrl(baseUrl);
      setActive(project);
    } catch (error) {
      console.error('fetching url error:', error);
+      setError('Failed to load project preview. Please try again.');
+    } finally {
+      setIsLoading(false);
    }
  };
📝 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
try {
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const json = await response.json();
const baseUrl = `http://${json.domain}`;
setIframeUrl(baseUrl);
setActive(project);
} catch (error) {
console.error('fetching url error:', error);
}
};
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getWebUrl = async (project) => {
if (!project) return;
const projectPath = project.path;
setIsLoading(true);
setError(null);
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 fetch project URL (${response.status})`);
}
const json = await response.json();
const baseUrl = `http://${json.domain}`;
setIframeUrl(baseUrl);
setActive(project);
} catch (error) {
console.error('fetching url error:', error);
setError('Failed to load project preview. Please try again.');
} finally {
setIsLoading(false);
}
};


return (
<>
<AnimatePresence mode="wait">
{active && (
<motion.div
onClick={() => setActive(null)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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' }}
/>
)}
</AnimatePresence>

<AnimatePresence mode="wait">
{active ? (
<div className="fixed inset-0 grid place-items-center z-[80] m-4">
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className="flex absolute top-4 right-4 items-center justify-center bg-white/90 hover:bg-white rounded-full h-8 w-8"
onClick={() => setActive(null)}
>
<X className="h-4 w-4 z-50" />
</motion.button>

<motion.div
layoutId={`card-${active.id}`}
ref={ref}
className="w-full h-full flex flex-col bg-white dark:bg-neutral-900 rounded-2xl overflow-hidden"
style={{ willChange: 'transform, opacity' }}
>
<motion.div className="flex-1 p-6 h-[50%]">
<motion.div
layoutId={`content-${active.id}`}
className="h-full"
>
<motion.h3
layoutId={`title-${active.id}`}
className="text-xl font-semibold text-gray-900 dark:text-gray-100"
>
{active.name}
</motion.h3>
<motion.div
layoutId={`meta-${active.id}`}
className="mt-2 w-full h-full"
>
<iframe
src={iframeUrl}
className="w-full h-[100%]"
title="Project Preview"
/>
Comment on lines +107 to +111
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

Enhance iframe security and add loading state

The iframe implementation lacks security attributes and doesn't show any loading indicator while the content is being fetched.

                    <iframe
                      src={iframeUrl}
                      className="w-full h-[100%]"
                      title="Project Preview"
+                      sandbox="allow-scripts allow-same-origin"
+                      loading="lazy"
+                      referrerPolicy="no-referrer"
                    />
+                    {isLoading && (
+                      <div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/80">
+                        <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600"></div>
+                      </div>
+                    )}
+                    {error && (
+                      <div className="absolute inset-0 flex items-center justify-center bg-white/90 dark:bg-black/90">
+                        <div className="text-red-500 text-center p-4">
+                          <p>{error}</p>
+                          <button 
+                            className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg" 
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              getWebUrl(active);
+                            }}
+                          >
+                            Retry
+                          </button>
+                        </div>
+                      </div>
+                    )}
📝 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
<iframe
src={iframeUrl}
className="w-full h-[100%]"
title="Project Preview"
/>
<iframe
src={iframeUrl}
className="w-full h-[100%]"
title="Project Preview"
sandbox="allow-scripts allow-same-origin"
loading="lazy"
referrerPolicy="no-referrer"
/>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/80">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600"></div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-white/90 dark:bg-black/90">
<div className="text-red-500 text-center p-4">
<p>{error}</p>
<button
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg"
onClick={(e) => {
e.stopPropagation();
getWebUrl(active);
}}
>
Retry
</button>
</div>
</div>
)}

</motion.div>
</motion.div>
</motion.div>
</motion.div>
</div>
) : null}
</AnimatePresence>

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{projects.map((project) => (
<motion.div
key={project.id}
layoutId={`card-${project.id}`}
onClick={() => getWebUrl(project)}
className="group cursor-pointer"
>
<motion.div
layoutId={`image-container-${project.id}`}
className="relative rounded-xl overflow-hidden"
>
<motion.div layoutId={`image-${project.id}`}>
<Image
src={project.image}
alt={project.name}
width={600}
height={200}
className="w-full h-48 object-cover transition duration-300 group-hover:scale-105"
/>
</motion.div>

<motion.div
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-black/40 flex items-center justify-center"
>
<span className="text-white font-medium px-4 py-2 rounded-lg bg-white/20 backdrop-blur-sm">
View Project
</span>
</motion.div>
</motion.div>

<motion.div layoutId={`content-${project.id}`} className="mt-3">
<motion.h3
layoutId={`title-${project.id}`}
className="font-medium text-gray-900 dark:text-gray-100"
>
{project.name}
</motion.h3>
<motion.div
layoutId={`meta-${project.id}`}
className="mt-1 text-sm text-gray-500"
>
{project.author}
</motion.div>
</motion.div>
</motion.div>
))}
</div>
</>
);
}
81 changes: 5 additions & 76 deletions frontend/src/components/root/projects-section.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,6 @@
import { gql, useQuery } from '@apollo/client';
import Image from 'next/image';

const FETCH_PUBLIC_PROJECTS = gql`
query FetchPublicProjects($input: FetchPublicProjectsInputs!) {
fetchPublicProjects(input: $input) {
id
projectName
createdAt
user {
username
}
photoUrl
subNumber
}
}
`;

const ProjectCard = ({ project }) => (
<div className="cursor-pointer group space-y-3">
{/* Image section with card styling */}
<div className="relative rounded-lg overflow-hidden shadow-md transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl">
<Image
src={project.image}
alt={project.name}
width={600}
height={200}
className="w-full h-36 object-cover transition-all duration-300 group-hover:brightness-75"
/>
<div className="absolute bottom-0 right-0 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded-tl-md">
{project.forkNum} forks
</div>

{/* "View Detail" hover effect */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md font-medium transform transition-transform duration-300 scale-90 group-hover:scale-100">
View Detail
</button>
</div>
</div>

{/* Info section */}
<div className="px-1">
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{project.name}
</h3>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="inline-block w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 mr-2"></span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{project.author}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{formatDate(project.createDate)}
</span>
</div>
</div>
</div>
</div>
);

const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
import { useQuery } from '@apollo/client';
import { FETCH_PUBLIC_PROJECTS } from '@/graphql/request';
import { ExpandableCard } from './expand-card';

export function ProjectsSection() {
// Execute the GraphQL query with provided variables
Expand All @@ -83,6 +15,7 @@ export function ProjectsSection() {
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',
Expand Down Expand Up @@ -112,11 +45,7 @@ export function ProjectsSection() {
) : (
<div>
{transformedProjects.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{transformedProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
<ExpandableCard projects={transformedProjects} />
) : (
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Missing type definition for props

The ExpandableCard component doesn't have TypeScript type definitions, which could lead to runtime errors if the wrong data structure is passed.


🏁 Script executed:

#!/bin/bash
# Check if the ExpandableCard component has TypeScript type definitions in its implementation
rg -t tsx "export function ExpandableCard\(.+\): " --include=./frontend/src/components/root/expand-card.tsx

Length of output: 551


I'll re-run the verification using a revised shell script that avoids the unsupported --include flag and also searches for any type/interface definitions (e.g., ExpandableCardProps) in the file. This should help us confirm whether the ExpandableCard component has TypeScript props definitions.


🏁 Script executed:

#!/bin/bash
echo "Searching for the ExpandableCard component definition in expand-card.tsx..."
rg -t tsx "export function ExpandableCard\\(" frontend/src/components/root/expand-card.tsx

echo ""
echo "Searching for a type or interface definition for the ExpandableCard props (e.g., ExpandableCardProps)..."
rg -t tsx "(interface|type) ExpandableCardProps" frontend/src/components/root/expand-card.tsx

Length of output: 634


Action Required: Add Type Definitions for ExpandableCard Props

Our verification checks indicate that the ExpandableCard component in frontend/src/components/root/expand-card.tsx does not include an explicit type or interface for its props. This omission can lead to runtime errors if an unexpected data structure is passed. Please add a proper type definition (for example, by defining an interface such as ExpandableCardProps) to ensure type safety.

  • Location: frontend/src/components/root/expand-card.tsx

// Show message when no projects are available
<div className="text-center py-10 text-gray-500 dark:text-gray-400">
Expand Down
6 changes: 0 additions & 6 deletions frontend/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import {
SidebarRail,
SidebarFooter,
} from './ui/sidebar';
import { cn } from '@/lib/utils';
import { ProjectContext } from './chat/code-engine/project-context';
import { useRouter } from 'next/navigation';

interface SidebarProps {
setIsModalOpen: (value: boolean) => void; // Parent setter to update collapse state
Expand All @@ -41,9 +39,6 @@ export function ChatSideBar({
setIsModalOpen,
isCollapsed,
setIsCollapsed,
isMobile,
chatListUpdated,
setChatListUpdated,
chats,
loading,
error,
Expand All @@ -60,7 +55,6 @@ export function ChatSideBar({
const event = new Event(EventEnum.NEW_CHAT);
window.dispatchEvent(event);
}, []);
const router = useRouter();

if (loading) return <SidebarSkeleton />;
if (error) {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/graphql/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ export interface ModelTagsData {
getAvailableModelTags: string[];
}

export const FETCH_PUBLIC_PROJECTS = gql`
query FetchPublicProjects($input: FetchPublicProjectsInputs!) {
fetchPublicProjects(input: $input) {
id
projectName
projectPath
createdAt
user {
username
}
photoUrl
subNumber
}
}
`;

export const CREATE_CHAT = gql`
mutation CreateChat($input: NewChatInput!) {
createChat(newChatInput: $input) {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/graphql/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ type Project {
projectPath: String!
subNumber: Float!

"""Projects that are copies of this project"""
"""
Projects that are copies of this project
"""
subscribers: [Project!]
uniqueProjectId: String!
updatedAt: Date!
Expand Down Expand Up @@ -224,7 +226,8 @@ type User {
isActive: Boolean!
isDeleted: Boolean!
projects: [Project!]!
subscribedProjects: [Project!] @deprecated(reason: "Use projects with forkedFromId instead")
subscribedProjects: [Project!]
@deprecated(reason: "Use projects with forkedFromId instead")
updatedAt: Date!
username: String!
}
Loading