Skip to content

Commit 34a86c6

Browse files
NarwhalChenautofix-ci[bot]
andauthoredMar 5, 2025
feat(frontend): implement expandable project cards and fetch public p… (#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>
1 parent 20301ed commit 34a86c6

File tree

5 files changed

+199
-84
lines changed

5 files changed

+199
-84
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
'use client';
2+
import Image from 'next/image';
3+
import React, { useEffect, useRef, useState } from 'react';
4+
import { AnimatePresence, motion } from 'framer-motion';
5+
import { X } from 'lucide-react';
6+
7+
export function ExpandableCard({ projects }) {
8+
const [active, setActive] = useState(null);
9+
const [iframeUrl, setIframeUrl] = useState('');
10+
const ref = useRef<HTMLDivElement>(null);
11+
12+
useEffect(() => {
13+
function onKeyDown(event: KeyboardEvent) {
14+
if (event.key === 'Escape') {
15+
setActive(null);
16+
}
17+
}
18+
19+
if (active && typeof active === 'object') {
20+
document.body.style.overflow = 'hidden';
21+
} else {
22+
document.body.style.overflow = 'auto';
23+
}
24+
25+
window.addEventListener('keydown', onKeyDown);
26+
return () => window.removeEventListener('keydown', onKeyDown);
27+
}, [active]);
28+
29+
const getWebUrl = async (project) => {
30+
if (!project) return;
31+
console.log('project:', project);
32+
const projectPath = project.path;
33+
34+
try {
35+
const response = await fetch(
36+
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
37+
{
38+
method: 'GET',
39+
headers: {
40+
'Content-Type': 'application/json',
41+
},
42+
}
43+
);
44+
const json = await response.json();
45+
const baseUrl = `http://${json.domain}`;
46+
setIframeUrl(baseUrl);
47+
setActive(project);
48+
} catch (error) {
49+
console.error('fetching url error:', error);
50+
}
51+
};
52+
53+
return (
54+
<>
55+
<AnimatePresence mode="wait">
56+
{active && (
57+
<motion.div
58+
onClick={() => setActive(null)}
59+
initial={{ opacity: 0 }}
60+
animate={{ opacity: 1 }}
61+
exit={{ opacity: 0 }}
62+
transition={{
63+
duration: 0.3,
64+
ease: [0.4, 0, 0.2, 1],
65+
}}
66+
className="fixed inset-0 backdrop-blur-[2px] bg-black/20 h-full w-full z-50"
67+
style={{ willChange: 'opacity' }}
68+
/>
69+
)}
70+
</AnimatePresence>
71+
72+
<AnimatePresence mode="wait">
73+
{active ? (
74+
<div className="fixed inset-0 grid place-items-center z-[80] m-4">
75+
<motion.button
76+
initial={{ opacity: 0, scale: 0.9 }}
77+
animate={{ opacity: 1, scale: 1 }}
78+
exit={{ opacity: 0, scale: 0.9 }}
79+
transition={{ duration: 0.2 }}
80+
className="flex absolute top-4 right-4 items-center justify-center bg-white/90 hover:bg-white rounded-full h-8 w-8"
81+
onClick={() => setActive(null)}
82+
>
83+
<X className="h-4 w-4 z-50" />
84+
</motion.button>
85+
86+
<motion.div
87+
layoutId={`card-${active.id}`}
88+
ref={ref}
89+
className="w-full h-full flex flex-col bg-white dark:bg-neutral-900 rounded-2xl overflow-hidden"
90+
style={{ willChange: 'transform, opacity' }}
91+
>
92+
<motion.div className="flex-1 p-6 h-[50%]">
93+
<motion.div
94+
layoutId={`content-${active.id}`}
95+
className="h-full"
96+
>
97+
<motion.h3
98+
layoutId={`title-${active.id}`}
99+
className="text-xl font-semibold text-gray-900 dark:text-gray-100"
100+
>
101+
{active.name}
102+
</motion.h3>
103+
<motion.div
104+
layoutId={`meta-${active.id}`}
105+
className="mt-2 w-full h-full"
106+
>
107+
<iframe
108+
src={iframeUrl}
109+
className="w-full h-[100%]"
110+
title="Project Preview"
111+
/>
112+
</motion.div>
113+
</motion.div>
114+
</motion.div>
115+
</motion.div>
116+
</div>
117+
) : null}
118+
</AnimatePresence>
119+
120+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
121+
{projects.map((project) => (
122+
<motion.div
123+
key={project.id}
124+
layoutId={`card-${project.id}`}
125+
onClick={() => getWebUrl(project)}
126+
className="group cursor-pointer"
127+
>
128+
<motion.div
129+
layoutId={`image-container-${project.id}`}
130+
className="relative rounded-xl overflow-hidden"
131+
>
132+
<motion.div layoutId={`image-${project.id}`}>
133+
<Image
134+
src={project.image}
135+
alt={project.name}
136+
width={600}
137+
height={200}
138+
className="w-full h-48 object-cover transition duration-300 group-hover:scale-105"
139+
/>
140+
</motion.div>
141+
142+
<motion.div
143+
initial={{ opacity: 0 }}
144+
whileHover={{ opacity: 1 }}
145+
transition={{ duration: 0.2 }}
146+
className="absolute inset-0 bg-black/40 flex items-center justify-center"
147+
>
148+
<span className="text-white font-medium px-4 py-2 rounded-lg bg-white/20 backdrop-blur-sm">
149+
View Project
150+
</span>
151+
</motion.div>
152+
</motion.div>
153+
154+
<motion.div layoutId={`content-${project.id}`} className="mt-3">
155+
<motion.h3
156+
layoutId={`title-${project.id}`}
157+
className="font-medium text-gray-900 dark:text-gray-100"
158+
>
159+
{project.name}
160+
</motion.h3>
161+
<motion.div
162+
layoutId={`meta-${project.id}`}
163+
className="mt-1 text-sm text-gray-500"
164+
>
165+
{project.author}
166+
</motion.div>
167+
</motion.div>
168+
</motion.div>
169+
))}
170+
</div>
171+
</>
172+
);
173+
}

‎frontend/src/components/root/projects-section.tsx

+5-76
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,6 @@
1-
import { gql, useQuery } from '@apollo/client';
2-
import Image from 'next/image';
3-
4-
const FETCH_PUBLIC_PROJECTS = gql`
5-
query FetchPublicProjects($input: FetchPublicProjectsInputs!) {
6-
fetchPublicProjects(input: $input) {
7-
id
8-
projectName
9-
createdAt
10-
user {
11-
username
12-
}
13-
photoUrl
14-
subNumber
15-
}
16-
}
17-
`;
18-
19-
const ProjectCard = ({ project }) => (
20-
<div className="cursor-pointer group space-y-3">
21-
{/* Image section with card styling */}
22-
<div className="relative rounded-lg overflow-hidden shadow-md transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl">
23-
<Image
24-
src={project.image}
25-
alt={project.name}
26-
width={600}
27-
height={200}
28-
className="w-full h-36 object-cover transition-all duration-300 group-hover:brightness-75"
29-
/>
30-
<div className="absolute bottom-0 right-0 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded-tl-md">
31-
{project.forkNum} forks
32-
</div>
33-
34-
{/* "View Detail" hover effect */}
35-
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
36-
<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">
37-
View Detail
38-
</button>
39-
</div>
40-
</div>
41-
42-
{/* Info section */}
43-
<div className="px-1">
44-
<div className="flex flex-col space-y-2">
45-
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
46-
{project.name}
47-
</h3>
48-
<div className="flex items-center justify-between">
49-
<div className="flex items-center">
50-
<span className="inline-block w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 mr-2"></span>
51-
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
52-
{project.author}
53-
</span>
54-
</div>
55-
<span className="text-xs text-gray-500 dark:text-gray-400">
56-
{formatDate(project.createDate)}
57-
</span>
58-
</div>
59-
</div>
60-
</div>
61-
</div>
62-
);
63-
64-
const formatDate = (dateString) => {
65-
const date = new Date(dateString);
66-
return date.toLocaleDateString('en-US', {
67-
year: 'numeric',
68-
month: 'short',
69-
day: 'numeric',
70-
});
71-
};
1+
import { useQuery } from '@apollo/client';
2+
import { FETCH_PUBLIC_PROJECTS } from '@/graphql/request';
3+
import { ExpandableCard } from './expand-card';
724

735
export function ProjectsSection() {
746
// Execute the GraphQL query with provided variables
@@ -83,6 +15,7 @@ export function ProjectsSection() {
8315
const transformedProjects = fetchedProjects.map((project) => ({
8416
id: project.id,
8517
name: project.projectName,
18+
path: project.projectPath,
8619
createDate: project.createdAt
8720
? new Date(project.createdAt).toISOString().split('T')[0]
8821
: '2025-01-01',
@@ -112,11 +45,7 @@ export function ProjectsSection() {
11245
) : (
11346
<div>
11447
{transformedProjects.length > 0 ? (
115-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
116-
{transformedProjects.map((project) => (
117-
<ProjectCard key={project.id} project={project} />
118-
))}
119-
</div>
48+
<ExpandableCard projects={transformedProjects} />
12049
) : (
12150
// Show message when no projects are available
12251
<div className="text-center py-10 text-gray-500 dark:text-gray-400">

‎frontend/src/components/sidebar.tsx

-6
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ import {
1919
SidebarRail,
2020
SidebarFooter,
2121
} from './ui/sidebar';
22-
import { cn } from '@/lib/utils';
2322
import { ProjectContext } from './chat/code-engine/project-context';
24-
import { useRouter } from 'next/navigation';
2523

2624
interface SidebarProps {
2725
setIsModalOpen: (value: boolean) => void; // Parent setter to update collapse state
@@ -41,9 +39,6 @@ export function ChatSideBar({
4139
setIsModalOpen,
4240
isCollapsed,
4341
setIsCollapsed,
44-
isMobile,
45-
chatListUpdated,
46-
setChatListUpdated,
4742
chats,
4843
loading,
4944
error,
@@ -60,7 +55,6 @@ export function ChatSideBar({
6055
const event = new Event(EventEnum.NEW_CHAT);
6156
window.dispatchEvent(event);
6257
}, []);
63-
const router = useRouter();
6458

6559
if (loading) return <SidebarSkeleton />;
6660
if (error) {

‎frontend/src/graphql/request.ts

+16
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ export interface ModelTagsData {
1717
getAvailableModelTags: string[];
1818
}
1919

20+
export const FETCH_PUBLIC_PROJECTS = gql`
21+
query FetchPublicProjects($input: FetchPublicProjectsInputs!) {
22+
fetchPublicProjects(input: $input) {
23+
id
24+
projectName
25+
projectPath
26+
createdAt
27+
user {
28+
username
29+
}
30+
photoUrl
31+
subNumber
32+
}
33+
}
34+
`;
35+
2036
export const CREATE_CHAT = gql`
2137
mutation CreateChat($input: NewChatInput!) {
2238
createChat(newChatInput: $input) {

‎frontend/src/graphql/schema.gql

+5-2
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ type Project {
137137
projectPath: String!
138138
subNumber: Float!
139139

140-
"""Projects that are copies of this project"""
140+
"""
141+
Projects that are copies of this project
142+
"""
141143
subscribers: [Project!]
142144
uniqueProjectId: String!
143145
updatedAt: Date!
@@ -224,7 +226,8 @@ type User {
224226
isActive: Boolean!
225227
isDeleted: Boolean!
226228
projects: [Project!]!
227-
subscribedProjects: [Project!] @deprecated(reason: "Use projects with forkedFromId instead")
229+
subscribedProjects: [Project!]
230+
@deprecated(reason: "Use projects with forkedFromId instead")
228231
updatedAt: Date!
229232
username: String!
230233
}

0 commit comments

Comments
 (0)