Skip to content

Commit

Permalink
feat(frontend): implement expandable project cards and fetch public p… (
Browse files Browse the repository at this point in the history
#152)

…rojects query

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced an interactive `ExpandableCard` component for project
previews, featuring a modal overlay with smooth animations.
- Added a custom hook for detecting clicks outside of elements to
enhance user interactions.

- **Refactor**
- Streamlined the public projects display by integrating a new GraphQL
query and simplifying the component structure for improved data
retrieval and presentation.
  
- **Chores**
- Removed unused props from the `SidebarProps` interface and the
`ChatSideBar` component for a cleaner codebase.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
NarwhalChen and autofix-ci[bot] authored Mar 5, 2025
1 parent 20301ed commit 34a86c6
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 84 deletions.
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 }) {
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);
}
};

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"
/>
</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} />
) : (
// 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!
}

0 comments on commit 34a86c6

Please sign in to comment.