diff --git a/src/app/(marketing)/projects/view/project-browser.tsx b/src/app/(marketing)/projects/view/project-browser.tsx new file mode 100644 index 00000000..d45d8b67 --- /dev/null +++ b/src/app/(marketing)/projects/view/project-browser.tsx @@ -0,0 +1,282 @@ +'use client' + +import Link from 'next/link' +import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' +import { ArrowLeft, ArrowRight, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Project } from '../types/project' +import { formatDate } from '../utilities/date-format' +import { StackPills } from '../components/stack-pills' +import { StatusBadge } from '../components/status-badge' + +type Props = { + projects: Project[] +} + +type RefMap = Record + +const curve = [0.16, 1, 0.3, 1] as const + +export function ProjectBrowser({ projects }: Props) { + const [activeSlug, setActiveSlug] = useState(null) + const buttonRefs = useRef({}) + const closeRef = useRef(null) + const lastSlug = useRef(null) + + const activeIndex = useMemo(() => projects.findIndex((entry) => entry.slug === activeSlug), [projects, activeSlug]) + const activeProject = activeIndex >= 0 ? projects[activeIndex] : undefined + const previousProject = activeIndex > 0 ? projects[activeIndex - 1] : undefined + const nextProject = activeIndex >= 0 && activeIndex < projects.length - 1 ? projects[activeIndex + 1] : undefined + + useEffect(() => { + if (activeProject && closeRef.current) { + closeRef.current.focus() + } + }, [activeProject]) + + useEffect(() => { + if (!activeProject && lastSlug.current) { + const target = buttonRefs.current[lastSlug.current] + + if (target) { + target.focus() + } + } + }, [activeProject]) + + useEffect(() => { + if (!activeProject) { + return + } + + function onKey(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault() + closeDetail() + } + + if (event.key === 'ArrowLeft') { + event.preventDefault() + openPrevious() + } + + if (event.key === 'ArrowRight') { + event.preventDefault() + openNext() + } + } + + window.addEventListener('keydown', onKey) + + return () => { + window.removeEventListener('keydown', onKey) + } + }, [activeProject, previousProject, nextProject]) + + function openDetail(slug: string) { + lastSlug.current = slug + setActiveSlug(slug) + } + + function closeDetail() { + setActiveSlug(null) + } + + function openPrevious() { + if (previousProject) { + openDetail(previousProject.slug) + } + } + + function openNext() { + if (nextProject) { + openDetail(nextProject.slug) + } + } + + function setButtonRef(slug: string, node: HTMLButtonElement | null) { + buttonRefs.current[slug] = node + } + + return ( + +
+ {projects.map((project) => ( + + openDetail(project.slug)} + ref={(node) => setButtonRef(project.slug, node)} + className="flex w-full flex-col gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" + aria-expanded={activeSlug === project.slug} + aria-controls={`project-${project.slug}`} + > +
+ + {project.title} + + + {project.categories[0] ?? 'Project'} + +
+

{project.summary}

+
+ {project.dates.year} + + Updated {formatDate(project.dates.updated)} +
+
+
+ ))} +
+ + {activeProject && ( + { + if (event.target === event.currentTarget) { + closeDetail() + } + }} + > + +
+
+ + {activeProject.title} + +

{activeProject.description}

+
+ Started {formatDate(activeProject.dates.start)} + {activeProject.dates.end && ( + Finished {formatDate(activeProject.dates.end)} + )} + Updated {formatDate(activeProject.dates.updated)} +
+
+ +
+
+ + {activeProject.categories.map((category) => ( + + {category} + + ))} +
+ +
+ {activeProject.links?.repo && ( + + GitHub + + + )} + {activeProject.links?.live && ( + + Live + + + )} + {activeProject.links?.docs && ( + + Docs + + + )} +
+
+ +
+ + + Full view + + +
+
+
+
+ )} +
+
+ ) +} diff --git a/src/app/(marketing)/projects/view/project-detail-view.tsx b/src/app/(marketing)/projects/view/project-detail-view.tsx index 6b7044c5..108b0670 100644 --- a/src/app/(marketing)/projects/view/project-detail-view.tsx +++ b/src/app/(marketing)/projects/view/project-detail-view.tsx @@ -21,9 +21,9 @@ type Props = { export function ProjectDetailView({ project, previous, next }: Props) { return ( -
+
-
+
@@ -37,11 +37,11 @@ export function ProjectDetailView({ project, previous, next }: Props) { ))}
-
+

{project.title}

-

{project.description}

+

{project.summary}

-
+
Started {formatDate(project.dates.start)} @@ -54,8 +54,8 @@ export function ProjectDetailView({ project, previous, next }: Props) { Updated {formatDate(project.dates.updated)}
- -
+ +
{project.links?.live && (
- + {project.media && }
-
+

About this build

{project.description} diff --git a/src/app/(marketing)/projects/view/projects-view.tsx b/src/app/(marketing)/projects/view/projects-view.tsx index ff2bbb52..511e5151 100644 --- a/src/app/(marketing)/projects/view/projects-view.tsx +++ b/src/app/(marketing)/projects/view/projects-view.tsx @@ -1,8 +1,7 @@ import { OptimizedPageTransition } from '@/components/ui/optimized-page-transition' -import { ProjectCard } from '../components/project-card' -import { ProjectFilters } from '../components/project-filters' import type { ProjectFilter } from '../types/project' -import { filterProjects, getCategories, getProjects, getStatuses, getYears } from '../utilities/project-registry' +import { filterProjects, getProjects } from '../utilities/project-registry' +import { ProjectBrowser } from './project-browser' type Props = { filter: ProjectFilter @@ -10,31 +9,23 @@ type Props = { export function ProjectsView({ filter }: Props) { const projects = filterProjects(getProjects(), filter) - const categories = getCategories() - const statuses = getStatuses() - const years = getYears() return (

-
-

Projects

-

Premium builds and experiments

+
+

Projects

+

Playful, minimal showcases

- SSR-first experiences with instant navigation, graceful degradation, and dark neutral aesthetics. Filter by status, category, or year without losing speed. + A tight list of builds with swift, view-like transitions into their details. Tap a title to expand, glide through next or previous projects, and close back to the list without losing your place.

- -
- {projects.map((project, index) => ( - - ))} - {projects.length === 0 && ( -
- No projects match this filter yet. Try broadening the selection. -
- )} -
+ + {projects.length === 0 && ( +
+ No projects match this view right now. +
+ )}
) diff --git a/src/components/blog/posts-client.tsx b/src/components/blog/posts-client.tsx index 7bd44142..a38063f2 100644 --- a/src/components/blog/posts-client.tsx +++ b/src/components/blog/posts-client.tsx @@ -90,7 +90,7 @@ function BlogCard({ post, index }: Props) { className="text-5xl font-bold text-neutral-300 dark:text-neutral-700/40 leading-none select-none tabular-nums shrink-0 w-16 text-right" aria-hidden="true" > - +
diff --git a/src/components/landing/activity/contribution-graph.tsx b/src/components/landing/activity/contribution-graph.tsx index ba76f8f4..fc3de0ee 100644 --- a/src/components/landing/activity/contribution-graph.tsx +++ b/src/components/landing/activity/contribution-graph.tsx @@ -476,7 +476,7 @@ export function ActivityContributionGraph({ {isVisible ? ( <> - contributions in + contributions in ) : ( 0 contributions in {year} diff --git a/src/components/landing/tech-stack-cloud.tsx b/src/components/landing/tech-stack-cloud.tsx index 08f527a5..01f1226a 100644 --- a/src/components/landing/tech-stack-cloud.tsx +++ b/src/components/landing/tech-stack-cloud.tsx @@ -139,7 +139,6 @@ function TechCard({ ...props }: TechCardProps) { const [showLabel, setShowLabel] = useState(false) - const [showLabel, setShowLabel] = useState(false) const Icon = Icons[icon] @@ -163,8 +162,9 @@ function TechCard({ {...props} >