diff --git a/app/[locale]/(standalone)/portfolio/[slug]/ProjectShowcase.tsx b/app/[locale]/(standalone)/portfolio/[slug]/ProjectShowcase.tsx index 817efeb..f8f8f2f 100644 --- a/app/[locale]/(standalone)/portfolio/[slug]/ProjectShowcase.tsx +++ b/app/[locale]/(standalone)/portfolio/[slug]/ProjectShowcase.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { AnimatePresence, motion, useReducedMotion, type MotionProps } from 'framer-motion'; import { ArrowLeft, ArrowRight, ArrowUpRight, Github, X } from 'lucide-react'; import type { PortfolioShowcaseProject, ShowcaseMedia } from '@/lib/portfolio-showcase'; @@ -60,7 +60,7 @@ function Screen({ type="button" onClick={onOpen} className={`group relative block w-full overflow-hidden text-start focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#111113] ${shell} ${className}`} - aria-label={`Open ${media.label}`} + aria-label={media.alt} > +
+
+ {[ + [labels.status, project.status ?? (isHebrew ? 'פעיל' : 'Active')], + [labels.period, project.year], + [labels.ownership, project.role], + ].map(([label, value]) => ( +
+ +

{value}

+
+ ))} +
+ +
    + {project.highlights.map(item => ( +
  • + + {item} +
  • + ))} +
+
+
+
+ {labels.stack} + {project.technologies.map(technology => ( + {technology} + ))} +
+
+ + ); +} + +function TechnicalEvidence({ + isHebrew, + eyebrow, + title, + body, + steps, + dark = false, +}: { + isHebrew: boolean; + eyebrow: string; + title: string; + body: string; + steps: string[]; + dark?: boolean; +}) { + return ( +
+
+
+
+ +

{body}

+
+
+

{title}

+
+ {steps.map((step, index) => ( +
+ {String(index + 1).padStart(2, '0')} +

{step}

+
+ ))} +
+

+ {isHebrew ? 'תרשים מפושט של גבולות המערכת' : 'Simplified system boundary'} +

+
+
+
+
+ ); +} + function EngineeringDecisions({ isHebrew, items, @@ -121,8 +206,8 @@ function EngineeringDecisions({ } function SharedHeader({ project, locale, isHebrew }: { project: PortfolioShowcaseProject; locale: string; isHebrew: boolean }) { - const portfolioHref = `/${locale}/portfolio`; - const cvHref = `/${locale}/cv`; + const portfolioHref = `/${locale}/yotam#work`; + const cvHref = `/${locale}/yotam#experience`; return (
@@ -176,6 +261,8 @@ function StarLinkerStory({ project, isHebrew, reveal, onOpen }: StoryProps) { + +
@@ -220,6 +307,25 @@ function StarLinkerStory({ project, isHebrew, reveal, onOpen }: StoryProps) {
) : null} + + + +

- {copy(isHebrew, 'ממסמך גולמי לממצא שאפשר להגן עליו.', 'From raw document to defensible finding.')} + {copy(isHebrew, 'ממסמך גולמי לממצא שאפשר לעקוב אחריו.', 'From raw document to traceable finding.')}

@@ -326,6 +434,24 @@ function RightFlowStory({ project, isHebrew, reveal, onOpen }: StoryProps) {
) : null} + + + +
@@ -431,6 +559,25 @@ function EnsemblisStory({ project, isHebrew, reveal }: StoryProps) {
+ + ); @@ -464,6 +611,8 @@ function WakeMyWayStory({ project, isHebrew, reveal, onOpen }: StoryProps) { + +
@@ -506,6 +655,25 @@ function WakeMyWayStory({ project, isHebrew, reveal, onOpen }: StoryProps) {
) : null} + + + +
@@ -613,6 +783,24 @@ function CartShiftStory({ project, isHebrew, reveal, onOpen }: StoryProps) {
) : null} + + - + ); } @@ -699,19 +887,38 @@ export default function ProjectShowcase({ project, nextProject, locale }: Props) const isHebrew = locale === 'he'; const reduceMotion = useReducedMotion(); const [activeMedia, setActiveMedia] = useState(null); - const portfolioHref = `/${locale}/portfolio`; + const closeButtonRef = useRef(null); + const restoreFocusRef = useRef(null); + const portfolioHref = `/${locale}/yotam#work`; useEffect(() => { if (!activeMedia) return; + const previousOverflow = document.body.style.overflow; + restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; document.body.style.overflow = 'hidden'; + + const focusCloseButton = window.requestAnimationFrame(() => closeButtonRef.current?.focus()); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setActiveMedia(null); + if (event.key === 'Escape') { + event.preventDefault(); + setActiveMedia(null); + return; + } + + if (event.key === 'Tab') { + event.preventDefault(); + closeButtonRef.current?.focus(); + } }; + window.addEventListener('keydown', onKeyDown); return () => { + window.cancelAnimationFrame(focusCloseButton); document.body.style.overflow = previousOverflow; window.removeEventListener('keydown', onKeyDown); + restoreFocusRef.current?.focus(); + restoreFocusRef.current = null; }; }, [activeMedia]); @@ -732,7 +939,7 @@ export default function ProjectShowcase({ project, nextProject, locale }: Props) const storyProps = { project, isHebrew, reveal, onOpen: setActiveMedia }; return ( -
+
{project.slug === 'starlinker' ? : null} @@ -753,7 +960,7 @@ export default function ProjectShowcase({ project, nextProject, locale }: Props) {activeMedia ? ( setActiveMedia(null)}> - + event.stopPropagation()}> diff --git a/app/[locale]/(standalone)/portfolio/[slug]/opengraph-image.tsx b/app/[locale]/(standalone)/portfolio/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..37be7ec --- /dev/null +++ b/app/[locale]/(standalone)/portfolio/[slug]/opengraph-image.tsx @@ -0,0 +1,103 @@ +import { ImageResponse } from 'next/og'; +import { getPortfolioShowcase } from '@/lib/portfolio-showcase'; + +export const alt = 'Yotam Faraggi project case study'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +type Props = { + params: Promise<{ locale: string; slug: string }>; +}; + +export default async function OpenGraphImage({ params }: Props) { + const { locale, slug } = await params; + const project = getPortfolioShowcase(slug, locale); + const isHebrew = locale === 'he'; + + const title = project?.title ?? 'Project case study'; + const descriptor = project?.descriptor ?? 'Product engineering'; + const summary = project?.summary ?? ''; + const accent = project?.accent ?? '#7367f0'; + const status = project?.status ?? (isHebrew ? 'פרויקט' : 'Project'); + + return new ImageResponse( + ( +
+
+ + Yotam Faraggi + + + {status} + +
+ +
+ + {descriptor} + +
+ {title} +
+
+ {summary} +
+
+ +
+ {project?.year ?? '2026'} + cart-shift.com/{locale}/portfolio/{slug} +
+
+ ), + size + ); +} diff --git a/app/[locale]/(standalone)/portfolio/[slug]/page.tsx b/app/[locale]/(standalone)/portfolio/[slug]/page.tsx index 5f3e18f..4460deb 100644 --- a/app/[locale]/(standalone)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(standalone)/portfolio/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import Script from 'next/script'; import { notFound } from 'next/navigation'; import { setRequestLocale } from 'next-intl/server'; import ProjectShowcase from './ProjectShowcase'; @@ -29,8 +30,6 @@ export async function generateMetadata({ params }: Props): Promise { const isHebrew = locale === 'he'; const title = `${project.title} - ${project.descriptor} | Yotam Faraggi`; const description = project.summary; - const image = project.hero?.src.startsWith('/') ? project.hero.src : '/images/portfolio-v2/hero-art.webp'; - return { title, description, @@ -48,14 +47,12 @@ export async function generateMetadata({ params }: Props): Promise { description, type: 'website', url: `${siteUrl}/${locale}/portfolio/${slug}`, - images: [{ url: image, alt: project.hero?.alt ?? project.title }], locale: isHebrew ? 'he_IL' : 'en_US', }, twitter: { card: 'summary_large_image', title, description, - images: [image], }, }; } @@ -69,6 +66,31 @@ export default async function PortfolioProjectPage({ params }: Props) { if (!project) notFound(); const nextProject = getNextPortfolioShowcase(slug, validLocale); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://cart-shift.com'; + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: project.title, + description: project.summary, + url: project.liveUrl ?? `${siteUrl}/${validLocale}/portfolio/${project.slug}`, + dateModified: project.updatedAt, + creator: { + '@type': 'Person', + name: 'Yotam Faraggi', + url: `${siteUrl}/${validLocale}/yotam`, + }, + applicationCategory: project.descriptor, + sameAs: [project.liveUrl, project.repositoryUrl].filter(Boolean), + }; - return ; + return ( + <> +