Skip to content
Draft
Show file tree
Hide file tree
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
282 changes: 282 additions & 0 deletions src/app/(marketing)/projects/view/project-browser.tsx
Original file line number Diff line number Diff line change
@@ -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<string, HTMLButtonElement | null>

const curve = [0.16, 1, 0.3, 1] as const

export function ProjectBrowser({ projects }: Props) {
const [activeSlug, setActiveSlug] = useState<string | null>(null)
const buttonRefs = useRef<RefMap>({})
const closeRef = useRef<HTMLButtonElement | null>(null)
const lastSlug = useRef<string | null>(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 (
<LayoutGroup>
<div className="grid gap-3">
{projects.map((project) => (
<motion.div
key={project.slug}
layoutId={`card-${project.slug}`}
transition={{ duration: 0.5, ease: curve }}
className="group rounded-2xl border border-border/60 bg-card/70 p-5 shadow-sm backdrop-blur transition hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-lg"
>
<motion.button
type="button"
layoutId={`trigger-${project.slug}`}
onClick={() => 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}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<motion.span
layoutId={`title-${project.slug}`}
className="text-lg font-semibold leading-tight text-foreground"
>
{project.title}
</motion.span>
<span className="rounded-full border border-border/60 bg-background/40 px-3 py-1 text-xs uppercase tracking-wide text-muted-foreground">
{project.categories[0] ?? 'Project'}
</span>
</div>
<p className="text-sm leading-relaxed text-muted-foreground line-clamp-2">{project.summary}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary">{project.dates.year}</span>
<StatusBadge status={project.status} />
<span className="rounded-full border border-border/60 px-3 py-1">Updated {formatDate(project.dates.updated)}</span>
</div>
</motion.button>
</motion.div>
))}
</div>
<AnimatePresence>
{activeProject && (
<motion.div
className="fixed inset-0 z-30 flex items-start justify-center bg-background/70 px-4 py-10 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: curve }}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeDetail()
}
}}
>
<motion.article
layoutId={`card-${activeProject.slug}`}
transition={{ duration: 0.6, ease: curve }}
className="relative w-full max-w-5xl max-h-[calc(100vh-80px)] overflow-y-auto rounded-3xl border border-border/70 bg-gradient-to-b from-background via-background-secondary/60 to-background/90 p-8 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={`${activeProject.title} details`}
id={`project-${activeProject.slug}`}
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<motion.p
layoutId={`title-${activeProject.slug}`}
className="text-2xl font-semibold leading-tight text-foreground"
>
{activeProject.title}
</motion.p>
<p className="text-sm leading-relaxed text-muted-foreground">{activeProject.description}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary">Started {formatDate(activeProject.dates.start)}</span>
{activeProject.dates.end && (
<span className="rounded-full bg-secondary/40 px-3 py-1 text-foreground">Finished {formatDate(activeProject.dates.end)}</span>
)}
<span className="rounded-full border border-border/60 px-3 py-1">Updated {formatDate(activeProject.dates.updated)}</span>
</div>
</div>
<button
type="button"
onClick={closeDetail}
ref={closeRef}
className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 text-sm text-muted-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Close project details"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<StatusBadge status={activeProject.status} />
{activeProject.categories.map((category) => (
<span
key={category}
className="rounded-full border border-border/70 px-3 py-1 uppercase tracking-wide"
>
{category}
</span>
))}
</div>
<StackPills stack={activeProject.stack} className="mt-4" />
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{activeProject.links?.repo && (
<Link
href={activeProject.links.repo}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/60 px-4 py-3 text-sm text-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span>GitHub</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
)}
{activeProject.links?.live && (
<Link
href={activeProject.links.live}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/60 px-4 py-3 text-sm text-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span>Live</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
)}
{activeProject.links?.docs && (
<Link
href={activeProject.links.docs}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/60 px-4 py-3 text-sm text-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span>Docs</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
)}
</div>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={closeDetail}
className="inline-flex items-center gap-2 rounded-full border border-border/70 px-4 py-2 text-sm text-muted-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Close
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={openPrevious}
disabled={!previousProject}
className="inline-flex items-center gap-2 rounded-full border border-border/70 px-4 py-2 text-sm text-muted-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-disabled={!previousProject}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Previous
</button>
<Link
href={`/projects/${activeProject.slug}`}
className="inline-flex items-center gap-2 rounded-full border border-border/70 px-4 py-2 text-sm text-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
Full view
</Link>
<button
type="button"
onClick={openNext}
disabled={!nextProject}
className="inline-flex items-center gap-2 rounded-full border border-border/70 px-4 py-2 text-sm text-muted-foreground transition hover:border-primary/60 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-disabled={!nextProject}
>
Next
<ArrowRight className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
</motion.article>
</motion.div>
)}
</AnimatePresence>
</LayoutGroup>
)
}
18 changes: 9 additions & 9 deletions src/app/(marketing)/projects/view/project-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type Props = {
export function ProjectDetailView({ project, previous, next }: Props) {
return (
<OptimizedPageTransition>
<article className="space-y-10">
<article className="mx-auto max-w-6xl space-y-10">
<ProjectNav previous={previous} next={next} />
<header className="space-y-4 rounded-2xl border border-border/60 bg-gradient-to-br from-background-secondary/40 via-background/80 to-background p-6 shadow-lg">
<header className="rounded-3xl border border-border/60 bg-gradient-to-b from-background/60 via-background-secondary/40 to-background/80 p-8 shadow-lg backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge status={project.status} />
<div className="flex flex-wrap gap-2">
Expand All @@ -37,11 +37,11 @@ export function ProjectDetailView({ project, previous, next }: Props) {
))}
</div>
</div>
<div className="space-y-3">
<div className="mt-4 space-y-3">
<h1 className="text-3xl font-semibold leading-tight text-foreground">{project.title}</h1>
<p className="text-sm leading-relaxed text-muted-foreground">{project.description}</p>
<p className="text-sm leading-relaxed text-muted-foreground">{project.summary}</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary">
Started {formatDate(project.dates.start)}
</span>
Expand All @@ -54,8 +54,8 @@ export function ProjectDetailView({ project, previous, next }: Props) {
Updated {formatDate(project.dates.updated)}
</span>
</div>
<StackPills stack={project.stack} />
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<StackPills stack={project.stack} className="mt-4" />
<div className="mt-6 flex flex-wrap gap-3 text-sm text-muted-foreground">
{project.links?.live && (
<Link
href={project.links.live}
Expand Down Expand Up @@ -88,10 +88,10 @@ export function ProjectDetailView({ project, previous, next }: Props) {
)}
</div>
</header>
<MediaFrame media={project.media} />
{project.media && <MediaFrame media={project.media} />}
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<div className="space-y-6">
<section className="rounded-xl border border-border/60 bg-card/60 p-6 shadow-sm">
<section className="rounded-2xl border border-border/60 bg-card/60 p-6 shadow-sm">
<h2 className="text-xl font-semibold text-foreground">About this build</h2>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{project.description}
Expand Down
33 changes: 12 additions & 21 deletions src/app/(marketing)/projects/view/projects-view.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,31 @@
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
}

export function ProjectsView({ filter }: Props) {
const projects = filterProjects(getProjects(), filter)
const categories = getCategories()
const statuses = getStatuses()
const years = getYears()

return (
<OptimizedPageTransition>
<section className="space-y-8">
<header className="rounded-2xl border border-border/60 bg-gradient-to-br from-background-secondary/40 via-background/80 to-background p-6 shadow-lg">
<p className="text-sm uppercase tracking-wide text-muted-foreground">Projects</p>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Premium builds and experiments</h1>
<header className="rounded-3xl border border-border/60 bg-gradient-to-br from-background/60 via-background-secondary/40 to-background/80 p-8 shadow-lg backdrop-blur">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Projects</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight text-foreground">Playful, minimal showcases</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
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.
</p>
</header>
<ProjectFilters categories={categories} statuses={statuses} years={years} active={filter} />
<div className="grid gap-4">
{projects.map((project, index) => (
<ProjectCard key={project.slug} project={project} index={index} />
))}
{projects.length === 0 && (
<div className="rounded-xl border border-border/60 bg-card/60 p-6 text-sm text-muted-foreground">
No projects match this filter yet. Try broadening the selection.
</div>
)}
</div>
<ProjectBrowser projects={projects} />
{projects.length === 0 && (
<div className="rounded-2xl border border-border/60 bg-card/60 p-6 text-sm text-muted-foreground">
No projects match this view right now.
</div>
)}
</section>
</OptimizedPageTransition>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/blog/posts-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<AnimatedNumber value={formattedIndex} duration={indexDuration} />
<AnimatedNumber value={formattedIndex} duration={indexDuration} initialProgress={0} />
</span>

<div className="flex-1 min-w-0 pt-1">
Expand Down
Loading