Skip to content
Open
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
73 changes: 66 additions & 7 deletions src/app/projects/page.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { useState, useMemo } from 'react'
import Link from 'next/link'
import Grid from '@mui/material/Grid'
import MuiCard from '@mui/material/Card'
Expand All @@ -15,7 +16,8 @@ import projects from '@/helper/projects'
import { CardProduct } from '@/components/products/CardProduct'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'
import { motion } from 'framer-motion'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { motion, AnimatePresence } from 'framer-motion'

function LinkIcon(props) {
return (
Expand Down Expand Up @@ -168,6 +170,8 @@ const styles = {
}

export default function Projects() {
const [searchQuery, setSearchQuery] = useState('')

const readyToDownload = projects.filter(
(p) => p.category === 'Ready to download'
)
Expand All @@ -176,9 +180,20 @@ export default function Projects() {
)
const ongoing = projects.filter((p) => p.category === 'Ongoing')

const sortedProjects = [...projects].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })
)
const sortedProjects = useMemo(() => {
return [...projects].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })
)
}, [])

const filteredProjects = useMemo(() => {
const query = searchQuery.toLowerCase().trim()
if (!query) return sortedProjects

return sortedProjects.filter((project) =>
(project.name || '').toLowerCase().includes(query)
)
}, [searchQuery, sortedProjects])

return (
<>
Expand All @@ -202,12 +217,56 @@ export default function Projects() {
Free, open-source, and built by a global community of developers, designers, researchers, and innovators.
</p>
</motion.div>

{/* Search Bar */}
<div className="mt-8 mb-12 flex justify-center">
<div className="relative w-full max-w-xl group">
<label htmlFor="project-search" className="sr-only">
Search projects by name
</label>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none z-10">
<FontAwesomeIcon icon={faSearch} className="h-4 w-4 text-zinc-400 group-focus-within:text-[#00843D] dark:group-focus-within:text-yellow-400 transition-colors duration-300" />
</div>
<input
id="project-search"
type="search"
aria-describedby="project-search-status"
className="block w-full pl-11 pr-4 py-3.5 border border-zinc-200 dark:border-zinc-700/50 rounded-2xl leading-5 bg-zinc-50/50 dark:bg-zinc-800/50 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[#00843D] dark:focus:ring-yellow-400/50 focus:border-transparent sm:text-sm transition-all duration-300 shadow-sm hover:shadow-md backdrop-blur-sm"
placeholder="Search for projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<p id="project-search-status" className="sr-only" aria-live="polite">
{filteredProjects.length === 0
? `No projects found matching ${searchQuery.trim()}.`
: `${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'} shown.`}
</p>
</div>

<div className="mb-16 grid grid-cols-1 gap-x-8 gap-y-16 lg:grid-cols-3">
{sortedProjects.map((product) => (
<CardProduct key={product.slug} product={product} />
))}
<AnimatePresence mode="popLayout">
{filteredProjects.map((product) => (
<motion.div
layout
key={product.slug}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
>
<CardProduct product={product} />
</motion.div>
))}
</AnimatePresence>
{filteredProjects.length === 0 && (
<div className="col-span-full py-20 text-center">
<p className="text-xl text-zinc-500 dark:text-zinc-400 font-mono">
No projects found matching &quot;{searchQuery}&quot;
</p>
</div>
)}
Comment on lines +249 to +269
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and examine the code at the specified lines
if [ -f "src/app/projects/page.jsx" ]; then
  wc -l src/app/projects/page.jsx
  echo "---"
  sed -n '235,265p' src/app/projects/page.jsx
else
  echo "File not found"
fi

Repository: AOSSIE-Org/Website

Length of output: 1223


🌐 Web query:

Framer Motion AnimatePresence popLayout mode behavior exit animation

💡 Result:

Framer Motion's AnimatePresence with mode="popLayout" pops exiting elements out of the document layout immediately using position: absolute, allowing surrounding elements to reflow right away while the exit animation plays. This pairs well with the layout prop for smooth list removals, as shown in the official example: {items.map(item => ( <motion.li layout exit={{ opacity: 0 }} key={item.id} /> ))} Key behaviors: - Exiting elements animate from their popped position (absolute positioned to match original layout spot). - Custom child components must use forwardRef to forward the ref to the DOM element. - Parent container needs position: relative (or non-static) for correct positioning during layout shifts, as transforms create new offset parents. - Exit animations like opacity: 0 should trigger, but there's a known open bug (Issue #2416 since 2023, still reported in 2025) where opacity exit animations on layout elements fail inconsistently—every other item may skip. Workaround: add another property like x: 100 to the exit. Official docs confirm exit animations work as intended with popLayout when used properly, emphasizing unique keys and proper refs.

Citations:


Delay the empty state message until exit animations complete.

At line 253, the empty message renders immediately when filteredProjects.length becomes zero, but with mode="popLayout", the exiting cards remain visible during their 0.3s exit animation. This creates a visual overlap where "No projects found" appears while the last cards are still fading out. Use the onExitComplete callback on AnimatePresence to gate the empty state render, or introduce a state variable to track when all exits finish.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/projects/page.jsx` around lines 239 - 259, The empty-state message
currently appears immediately when filteredProjects becomes empty causing
overlap with cards still animating out; modify the component to wait for
AnimatePresence's onExitComplete before showing the empty message by adding a
local state flag (e.g., exitsComplete or showEmpty) and toggling it in
AnimatePresence's onExitComplete callback, render the "No projects found
matching..." block only when filteredProjects.length === 0 && exitsComplete is
true, and ensure to reset the flag to false when filteredProjects becomes
non-empty (watch filteredProjects in useEffect) so AnimatePresence
(layout/motion divs) and CardProduct continue to behave as before.

</div>

{/*
Expand Down