feat: render portfolio routes from live localized database content

This commit is contained in:
2026-09-15 21:48:07 +03:30
parent 7830784694
commit 131f7efbf3
36 changed files with 272 additions and 376 deletions

View File

@@ -1,6 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
serverExternalPackages: ['better-sqlite3'],
outputFileTracingIncludes: {
'/*': ['./migrations/**/*'],
},
outputFileTracingExcludes: {
'*': ['./data/**/*', './backups/**/*', './**/auth-secret', './**/*.sqlite*', './.env*'],
},
experimental: {
globalNotFound: true,
},

View File

@@ -1,21 +1,5 @@
import type { Metadata } from 'next';
import { SiteRoot } from '@/app/SiteRoot';
import { PortfolioPage } from '@/app/PortfolioPage';
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
import { messagesByLocale } from '@/i18n/content';
import '@/styles/globals.css';
export const metadata: Metadata = messagesByLocale.en.metadata;
export default function EnglishLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<SiteRoot locale="en">
<PortfolioRouteShell homePath="/">
<PortfolioPage locale="en" />
{children}
</PortfolioRouteShell>
</SiteRoot>
);
import { PublicLayout } from '@/app/PublicLayout';
export default async function Layout({ children, }: { children: React.ReactNode; }) {
const locale = undefined;
return <PublicLayout locale={locale}>{children}</PublicLayout>;
}

View File

@@ -1,3 +1,5 @@
export default function Home() {
return null;
import { publicMetadata } from '@/app/PublicLayout';
export async function generateMetadata() {
return publicMetadata();
}
export default function Home() { return null; }

View File

@@ -1,24 +1,10 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CaseStudyContent } from '@/components/CaseStudyContent';
import { getAdjacentProjects, getProject, projectIds } from '@/data/projects';
type Props = { params: Promise<{ projectId: string }> };
export const dynamicParams = false;
export const generateStaticParams = () => projectIds.map((projectId) => ({ projectId }));
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { projectId } = await params;
const project = getProject('en', projectId);
if (!project) return {};
return { title: `${project.title} case study | Amirhossein Khalili`, description: project.caseStudy.introduction };
import { ProjectPage, projectMetadata } from '@/app/ProjectPage';
type Props = { params: Promise<{ projectId: string; }> };
export async function generateMetadata({ params }: Props) {
const values = await params;
return projectMetadata(values.projectId);
}
export default async function ProjectPage({ params }: Props) {
const { projectId } = await params;
const project = getProject('en', projectId);
const adjacent = getAdjacentProjects('en', projectId);
if (!project || !adjacent) notFound();
return <CaseStudyContent project={project} locale="en" {...adjacent} />;
export default async function Page({ params }: Props) {
const values = await params;
return <ProjectPage id={values.projectId} />;
}

View File

@@ -1,5 +1,6 @@
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return <CaseStudyDrawer locale="en">{children}</CaseStudyDrawer>;
import { getPublicRoute } from '@/server/public';
export default async function ProjectsLayout({ children, }: { children: React.ReactNode; }) {
const { portfolio } = await getPublicRoute();
return <CaseStudyDrawer portfolio={portfolio}>{children}</CaseStudyDrawer>;
}

View File

@@ -1,21 +0,0 @@
import type { Metadata } from 'next';
import { SiteRoot } from '@/app/SiteRoot';
import { PortfolioPage } from '@/app/PortfolioPage';
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
import { messagesByLocale } from '@/i18n/content';
import '@/styles/globals.css';
export const metadata: Metadata = messagesByLocale.fa.metadata;
export default function PersianLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<SiteRoot locale="fa">
<PortfolioRouteShell homePath="/fa">
<PortfolioPage locale="fa" />
{children}
</PortfolioRouteShell>
</SiteRoot>
);
}

View File

@@ -1,3 +0,0 @@
export default function PersianHome() {
return null;
}

View File

@@ -1,24 +0,0 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CaseStudyContent } from '@/components/CaseStudyContent';
import { getAdjacentProjects, getProject, projectIds } from '@/data/projects';
type Props = { params: Promise<{ projectId: string }> };
export const dynamicParams = false;
export const generateStaticParams = () => projectIds.map((projectId) => ({ projectId }));
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { projectId } = await params;
const project = getProject('fa', projectId);
if (!project) return {};
return { title: `مطالعه موردی ${project.title} | امیرحسین خلیلی`, description: project.caseStudy.introduction };
}
export default async function ProjectPage({ params }: Props) {
const { projectId } = await params;
const project = getProject('fa', projectId);
const adjacent = getAdjacentProjects('fa', projectId);
if (!project || !adjacent) notFound();
return <CaseStudyContent project={project} locale="fa" {...adjacent} />;
}

View File

@@ -1,5 +0,0 @@
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return <CaseStudyDrawer locale="fa">{children}</CaseStudyDrawer>;
}

View File

@@ -0,0 +1,5 @@
import { PublicLayout } from '@/app/PublicLayout';
export default async function Layout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) {
const { locale } = await params;
return <PublicLayout locale={locale}>{children}</PublicLayout>;
}

View File

@@ -0,0 +1,5 @@
import { publicMetadata } from '@/app/PublicLayout';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
return publicMetadata((await params).locale);
}
export default function Home() { return null; }

View File

@@ -0,0 +1,10 @@
import { ProjectPage, projectMetadata } from '@/app/ProjectPage';
type Props = { params: Promise<{ projectId: string; locale: string; }> };
export async function generateMetadata({ params }: Props) {
const values = await params;
return projectMetadata(values.projectId, values.locale);
}
export default async function Page({ params }: Props) {
const values = await params;
return <ProjectPage id={values.projectId} locale={values.locale} />;
}

View File

@@ -0,0 +1,6 @@
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
import { getPublicRoute } from '@/server/public';
export default async function ProjectsLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) {
const { portfolio } = await getPublicRoute([(await params).locale]);
return <CaseStudyDrawer portfolio={portfolio}>{children}</CaseStudyDrawer>;
}

View File

@@ -6,19 +6,19 @@ import { EducationSection } from '@/components/EducationSection';
import { MilestonesSection } from '@/components/MilestonesSection';
import { FooterSection } from '@/components/FooterSection';
import { MotionController } from '@/components/MotionController';
import type { Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
export function PortfolioPage({ locale }: { locale: Locale }) {
export function PortfolioPage({ portfolio }: PortfolioProps) {
return (
<main className="desktop-canvas" data-locale={locale}>
<main className="desktop-canvas" data-locale={portfolio.locale}>
<MotionController />
<ScenicHeroJourney locale={locale} />
<ProjectsSection locale={locale} />
<ExperienceSection locale={locale} />
<SkillsSection locale={locale} />
<EducationSection locale={locale} />
<MilestonesSection locale={locale} />
<FooterSection locale={locale} />
<ScenicHeroJourney portfolio={portfolio} />
{portfolio.sections.projects.visible && portfolio.projects.length > 0 && <ProjectsSection portfolio={portfolio} />}
{portfolio.sections.experience.visible && portfolio.experience.length > 0 && <ExperienceSection portfolio={portfolio} />}
{portfolio.sections.skills.visible && portfolio.skills.length > 0 && <SkillsSection portfolio={portfolio} />}
{portfolio.sections.education.visible && portfolio.education.length > 0 && <EducationSection portfolio={portfolio} />}
{portfolio.sections.milestones.visible && portfolio.milestones.length > 0 && <MilestonesSection portfolio={portfolio} />}
{portfolio.sections.footer.visible && <FooterSection portfolio={portfolio} />}
</main>
);
}

17
src/app/ProjectPage.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import { CaseStudyContent } from '@/components/CaseStudyContent';
import { getPublicRoute } from '@/server/public';
import { projectPath } from '@/content/contracts';
export async function projectMetadata(id: string, locale?: string): Promise<Metadata> {
const { portfolio, project } = await getPublicRoute([...(locale ? [locale] : []), 'projects', id]);
return { title: `${project!.title} | ${portfolio.metadata.title}`, description: project!.caseStudy.introduction,
alternates: { canonical: projectPath(portfolio.locale, id, portfolio.defaultLanguage), languages: Object.fromEntries(portfolio.languages.map(language => [language.code, projectPath(language.code, id, portfolio.defaultLanguage)])) } };
}
export async function ProjectPage({ id, locale }: { id: string; locale?: string }) {
const { portfolio, project } = await getPublicRoute([...(locale ? [locale] : []), 'projects', id]);
const index = portfolio.projects.findIndex(item => item.id === id);
const previous = portfolio.projects[(index - 1 + portfolio.projects.length) % portfolio.projects.length];
const next = portfolio.projects[(index + 1) % portfolio.projects.length];
return <CaseStudyContent project={project!} portfolio={portfolio} previous={previous} next={next} />;
}

21
src/app/PublicLayout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
import { SiteRoot } from './SiteRoot';
import { PortfolioPage } from './PortfolioPage';
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
import { getPublicRoute } from '@/server/public';
import { portfolioPath } from '@/content/contracts';
import '@/styles/globals.css';
export async function publicMetadata(locale?: string): Promise<Metadata> {
const { portfolio } = await getPublicRoute(locale ? [locale] : []);
const canonical = portfolioPath(portfolio.locale, portfolio.defaultLanguage);
return { ...portfolio.metadata, alternates: { canonical, languages: Object.fromEntries(portfolio.languages.map(language => [language.code, portfolioPath(language.code, portfolio.defaultLanguage)])) } };
}
export async function PublicLayout({ children, locale }: { children: React.ReactNode; locale?: string }) {
const { portfolio } = await getPublicRoute(locale ? [locale] : []);
return <SiteRoot locale={portfolio.locale} direction={portfolio.direction}>
<PortfolioRouteShell homePath={portfolioPath(portfolio.locale, portfolio.defaultLanguage)}>
<PortfolioPage portfolio={portfolio} />{children}
</PortfolioRouteShell>
</SiteRoot>;
}

View File

@@ -1,7 +1,6 @@
import type { ReactNode } from 'react';
import localFont from 'next/font/local';
import { ThemeProvider } from '@/context/ThemeContext';
import type { Locale } from '@/i18n/content';
const themeScript = `
try {
@@ -43,14 +42,16 @@ const peyda = localFont({
export function SiteRoot({
children,
locale,
direction = 'ltr',
}: {
children: ReactNode;
locale: Locale;
locale: string;
direction?: 'rtl' | 'ltr';
}) {
return (
<html
lang={locale}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
dir={direction}
suppressHydrationWarning
>
<head>

View File

@@ -1,72 +1,20 @@
import type { Metadata } from 'next';
import localFont from 'next/font/local';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { SiteRoot } from './SiteRoot';
import { readPublicSnapshot } from '@/server/public';
import { publicContent } from '@/server/content';
import '@/styles/globals.css';
import styles from './global-not-found.module.css';
const themeAndLocaleScript = `
try {
const root = document.documentElement;
const isPersian = /^\\/fa(?:\\/|$)/.test(window.location.pathname);
root.lang = isPersian ? 'fa' : 'en';
root.dir = isPersian ? 'rtl' : 'ltr';
const savedTheme = localStorage.getItem('portfolio-theme');
root.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
} catch {}
`;
const iranYekan = localFont({
src: '../../public/fonts/IRANYekanXVF.woff2',
variable: '--font-body',
weight: '100 1000',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
});
export const metadata: Metadata = {
title: '404 | Amirhossein Khalili',
description: 'The requested page could not be found.',
};
function NotFoundContent({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].notFound;
const isRtl = locale === 'fa';
return (
<section
className={`${styles.content} ${locale === 'fa' ? styles.persian : styles.english}`}
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
aria-labelledby={`not-found-title-${locale}`}
>
<p className={styles.code} aria-hidden="true">{copy.code}</p>
<span className={styles.divider} aria-hidden="true" />
<h1 id={`not-found-title-${locale}`}>{copy.title}</h1>
<p className={styles.description}>{copy.description}</p>
<Link href={isRtl ? '/fa' : '/'} className={styles.homeLink}>
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
<span>{copy.backHome}</span>
</Link>
</section>
);
}
export default function GlobalNotFound() {
return (
<html lang="en" dir="ltr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeAndLocaleScript }} />
</head>
<body className={iranYekan.variable}>
<main className={styles.page}>
<NotFoundContent locale="en" />
<NotFoundContent locale="fa" />
</main>
</body>
</html>
);
export default async function GlobalNotFound() {
const snapshot = await readPublicSnapshot();
const portfolio = publicContent(snapshot, snapshot.settings.defaultLanguage);
const copy = portfolio.messages.notFound;
return <SiteRoot locale={portfolio.locale} direction={portfolio.direction}>
<main className={styles.page}><section className={styles.content} aria-labelledby="not-found-title">
<p className={styles.code} aria-hidden="true">{copy.code}</p><span className={styles.divider} aria-hidden="true" />
<h1 id="not-found-title">{copy.title}</h1><p className={styles.description}>{copy.description}</p>
<Link href="/" className={styles.homeLink}>{portfolio.direction === 'rtl' ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}<span>{copy.backHome}</span></Link>
</section></main>
</SiteRoot>;
}

View File

@@ -1,24 +1,26 @@
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { getProjectPath, type Project } from '@/data/projects';
import type { PublicPortfolio } from '@/server/content';
import type { Project } from '@/data/projects';
import { projectPath } from '@/content/contracts';
import { CaseStudyGallery } from './CaseStudyGallery';
import styles from './CaseStudyDrawer.module.css';
export function CaseStudyContent({
project,
locale,
portfolio,
previous,
next,
}: {
project: Project;
locale: Locale;
portfolio: PublicPortfolio;
previous: Project;
next: Project;
}) {
const copy = messagesByLocale[locale].projects;
const { locale } = portfolio;
const copy = portfolio.messages.projects;
const caseStudy = project.caseStudy;
const isRtl = locale === 'fa';
const isRtl = portfolio.direction === 'rtl';
return (
<article className={styles.content}>
@@ -29,7 +31,7 @@ export function CaseStudyContent({
</h1>
</header>
<CaseStudyGallery key={project.id} items={caseStudy.gallery} locale={locale} />
<CaseStudyGallery key={project.id} items={caseStudy.gallery} portfolio={portfolio} />
<div className={styles.overview}>
<p id="case-study-introduction" className={styles.introduction}>{caseStudy.introduction}</p>
@@ -97,11 +99,11 @@ export function CaseStudyContent({
)}
<nav className={styles.projectNavigation} aria-label={copy.drawerLabel}>
<Link href={getProjectPath(locale, previous.id)} replace scroll={false} className={styles.projectNavLink}>
<Link href={projectPath(locale, previous.id, portfolio.defaultLanguage)} replace scroll={false} className={styles.projectNavLink}>
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
<span><small>{copy.previousCaseStudy}</small><strong>{previous.title}</strong></span>
</Link>
<Link href={getProjectPath(locale, next.id)} replace scroll={false} className={`${styles.projectNavLink} ${styles.nextProject}`}>
<Link href={projectPath(locale, next.id, portfolio.defaultLanguage)} replace scroll={false} className={`${styles.projectNavLink} ${styles.nextProject}`}>
<span><small>{copy.nextCaseStudy}</small><strong>{next.title}</strong></span>
{isRtl ? <ArrowLeft aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
</Link>

View File

@@ -3,14 +3,14 @@
import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PublicPortfolio } from '@/server/content';
import { useDismissCaseStudy } from './PortfolioRouteShell';
import { CaseStudyShare } from './CaseStudyShare';
import { useDrawerDrag } from './useDrawerDrag';
import styles from './CaseStudyDrawer.module.css';
export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) {
const copy = messagesByLocale[locale].projects;
export function CaseStudyDrawer({ children, portfolio }: { children: ReactNode; portfolio: PublicPortfolio }) {
const copy = portfolio.messages.projects;
const pathname = usePathname();
const dismissRoute = useDismissCaseStudy();
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -127,7 +127,7 @@ export function CaseStudyDrawer({ children, locale }: { children: ReactNode; loc
</div>
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
<div className={styles.actions}>
<CaseStudyShare locale={locale} />
<CaseStudyShare portfolio={portfolio} />
<button
type="button"
className={styles.iconButton}

View File

@@ -1,4 +1,6 @@
'use client';
import { formatMessage } from '@/content/contracts';
import Image from 'next/image';
import {
@@ -15,18 +17,19 @@ import {
type PointerEvent,
} from 'react';
import type { CaseStudyGalleryItem } from '@/data/projects';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PublicPortfolio } from '@/server/content';
import styles from './CaseStudyDrawer.module.css';
export function CaseStudyGallery({
items,
locale,
portfolio,
}: {
items: CaseStudyGalleryItem[];
locale: Locale;
portfolio: PublicPortfolio;
}) {
const copy = messagesByLocale[locale].projects;
const isRtl = locale === 'fa';
const { locale } = portfolio;
const copy = portfolio.messages.projects;
const isRtl = portfolio.direction === 'rtl';
const [selectedIndex, setSelectedIndex] = useState(0);
const [viewerOpen, setViewerOpen] = useState(false);
const viewerRef = useRef<HTMLDialogElement>(null);
@@ -142,7 +145,7 @@ export function CaseStudyGallery({
type="button"
className={styles.galleryStage}
onClick={() => setViewerOpen(true)}
aria-label={copy.openGalleryImage(selected.caption)}
aria-label={formatMessage(copy.openGalleryImage, { caption: selected.caption }, locale)}
aria-haspopup="dialog"
>
<Image
@@ -161,7 +164,7 @@ export function CaseStudyGallery({
</button>
<figcaption className={styles.galleryCaption}>
<span>{selected.caption}</span>
<span>{copy.imageCounter(selectedIndex + 1, items.length)}</span>
<span>{formatMessage(copy.imageCounter, { current: selectedIndex + 1, total: items.length }, locale)}</span>
</figcaption>
</figure>
@@ -175,7 +178,7 @@ export function CaseStudyGallery({
className={styles.thumbnailButton}
data-selected={index === selectedIndex}
onClick={() => selectImage(index)}
aria-label={copy.selectGalleryImage(index + 1, item.caption)}
aria-label={formatMessage(copy.selectGalleryImage, { index: index + 1, caption: item.caption }, locale)}
aria-pressed={index === selectedIndex}
>
<Image
@@ -221,7 +224,7 @@ export function CaseStudyGallery({
<header className={styles.viewerHeader}>
<div>
<strong>{selected.caption}</strong>
<span>{copy.imageCounter(selectedIndex + 1, items.length)}</span>
<span>{formatMessage(copy.imageCounter, { current: selectedIndex + 1, total: items.length }, locale)}</span>
</div>
<button
ref={closeRef}
@@ -276,7 +279,7 @@ export function CaseStudyGallery({
className={styles.viewerThumbnail}
data-selected={index === selectedIndex}
onClick={() => selectImage(index)}
aria-label={copy.selectGalleryImage(index + 1, item.caption)}
aria-label={formatMessage(copy.selectGalleryImage, { index: index + 1, caption: item.caption }, locale)}
aria-pressed={index === selectedIndex}
>
<Image src={item.image.src} width={item.image.width} height={item.image.height} alt="" sizes="6rem" quality={60} className={styles.thumbnailImage} />

View File

@@ -15,13 +15,13 @@ import {
useState,
type MouseEvent,
} from 'react';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './CaseStudyDrawer.module.css';
type CopyState = 'idle' | 'copied' | 'error';
export function CaseStudyShare({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].projects;
export function CaseStudyShare({ portfolio }: PortfolioProps) {
const copy = portfolio.messages.projects;
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);

View File

@@ -1,11 +1,9 @@
import Image from 'next/image';
import { getEducation } from '@/data/education';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './EducationSection.module.css';
export const EducationSection = ({ locale }: { locale: Locale }) => {
const education = getEducation(locale);
const copy = messagesByLocale[locale].education;
export const EducationSection = ({ portfolio }: PortfolioProps) => {
const copy = portfolio.messages.education;
return (
<section id="education" className={styles.educationSection}>
@@ -18,7 +16,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
</div>
{/* Degree Card */}
<div className={styles.card} data-reveal-group>
{portfolio.education.map(education => <div key={education.id} className={styles.card} data-reveal-group>
<div className={styles.crestCol} data-reveal="image">
<div className={styles.crestContainer}>
<Image
@@ -37,7 +35,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
<div className={styles.date}>{education.date}</div>
<p className={styles.description}>{education.description}</p>
</div>
</div>
</div>)}
</div>
</section>
);

View File

@@ -1,11 +1,12 @@
import { formatMessage } from '@/content/contracts';
import Image from 'next/image';
import { getExperiences } from '@/data/experience';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './ExperienceSection.module.css';
export const ExperienceSection = ({ locale }: { locale: Locale }) => {
const experiences = getExperiences(locale);
const copy = messagesByLocale[locale].experience;
export const ExperienceSection = ({ portfolio }: PortfolioProps) => {
const { locale } = portfolio;
const experiences = portfolio.experience;
const copy = portfolio.messages.experience;
return (
<section id="experience" className={styles.experienceSection}>
@@ -68,7 +69,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
<div className={styles.logoContainer}>
<Image
src={item.logo.src}
alt={copy.logoAlt(item.company)}
alt={formatMessage(copy.logoAlt, { company: item.company }, locale)}
fill
sizes="72px"
className={styles.logoImage}

View File

@@ -1,11 +1,11 @@
import IsometricFooter from './IsometricFooter/IsometricFooter';
import type { Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './FooterSection.module.css';
export const FooterSection = ({ locale }: { locale: Locale }) => {
export const FooterSection = ({ portfolio }: PortfolioProps) => {
return (
<footer id="footer" className={styles.footerSection}>
<IsometricFooter locale={locale} />
<IsometricFooter portfolio={portfolio} />
</footer>
);
};

View File

@@ -1,16 +1,17 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { portfolioPath } from '@/content/contracts';
import Link from 'next/link';
import { Check, Languages, Moon, Sun } from 'lucide-react';
import { useTheme } from '@/context/ThemeContext';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './HeroSection.module.css';
export function HeroControls({ locale }: { locale: Locale }) {
export function HeroControls({ portfolio }: PortfolioProps) {
const { locale } = portfolio;
const { theme, toggleTheme } = useTheme();
const copy = messagesByLocale[locale].hero;
const copy = portfolio.messages.hero;
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -44,14 +45,12 @@ export function HeroControls({ locale }: { locale: Locale }) {
<Languages size={23} strokeWidth={1.9} aria-hidden="true" />
</button>
<div id="language-menu" className={styles.languageMenu} role="menu" aria-label={copy.languageMenuLabel} data-open={isOpen}>
<Link href="/" hrefLang="en" role="menuitem" aria-current={locale === 'en' ? 'page' : undefined} className={styles.languageOption}>
<span className={styles.languageOptionLabel}><Image src="/images/flags/english.svg" alt="" width={20} height={20} unoptimized className={styles.languageFlag} aria-hidden="true" /><span lang="en" dir="ltr">{copy.englishLabel}</span></span>
{locale === 'en' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
</Link>
<Link href="/fa" hrefLang="fa" role="menuitem" aria-current={locale === 'fa' ? 'page' : undefined} className={styles.languageOption}>
{locale === 'fa' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
<span className={styles.languageOptionLabel}><Image src="/images/flags/persian.svg" alt="" width={20} height={20} unoptimized className={styles.languageFlag} aria-hidden="true" /><span lang="fa" dir="rtl">{copy.persianLabel}</span></span>
</Link>
{portfolio.languages.map(language => <Link key={language.code}
href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
aria-current={locale === language.code ? 'page' : undefined} className={styles.languageOption}>
<span className={styles.languageOptionLabel} lang={language.code} dir={language.direction}>{language.name}</span>
{locale === language.code && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
</Link>)}
</div>
</div>
</div>

View File

@@ -1,21 +1,21 @@
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
import Image from 'next/image';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import { HeroControls } from './HeroControls';
import styles from './HeroSection.module.css';
export const HeroSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].hero;
export const HeroSection = ({ portfolio }: PortfolioProps) => {
const copy = portfolio.messages.hero;
return (
<div className={styles.heroContentWrapper}>
<HeroControls locale={locale} />
<HeroControls portfolio={portfolio} />
<div className={styles.heroContent}>
<div className={styles.avatarWrapper}>
<div className={styles.avatarRingOuter}>
<div className={styles.avatarContainer}>
<Image
src="/images/avatar.png"
src={portfolio.profile.avatar.src}
alt={copy.avatarAlt}
fill
sizes="192px"
@@ -33,18 +33,24 @@ export const HeroSection = ({ locale }: { locale: Locale }) => {
<p className={styles.heroSubtitle}>{copy.subtitle}</p>
<div className={styles.actionButtons}>
<button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.blogLabel} title={copy.comingSoon} disabled>
{portfolio.profile.blogUrl ? <a href={portfolio.profile.blogUrl} target="_blank" rel="noreferrer" className={`pill-btn ${styles.heroButton}`} aria-label={copy.blogLabel}>
<BookOpenText size={17} strokeWidth={2} aria-hidden="true" />
{copy.blog}
</button>
<button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.coursesLabel} title={copy.comingSoon} disabled>
</a> : <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.blogLabel} title={copy.comingSoon} disabled>
<BookOpenText size={17} strokeWidth={2} aria-hidden="true" />
{copy.blog}
</button>}
{portfolio.profile.coursesUrl ? <a href={portfolio.profile.coursesUrl} target="_blank" rel="noreferrer" className={`pill-btn ${styles.heroButton}`} aria-label={copy.coursesLabel}>
<GraduationCap size={18} strokeWidth={2} aria-hidden="true" />
{copy.courses}
</button>
<a href="https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w" target="_blank" rel="noreferrer" className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`} aria-label={copy.contactLabel}>
</a> : <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.coursesLabel} title={copy.comingSoon} disabled>
<GraduationCap size={18} strokeWidth={2} aria-hidden="true" />
{copy.courses}
</button>}
{portfolio.profile.contactUrl && <a href={portfolio.profile.contactUrl} target="_blank" rel="noreferrer" className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`} aria-label={copy.contactLabel}>
<MessageCircle size={17} strokeWidth={2} aria-hidden="true" />
{copy.contact}
</a>
</a>}
</div>
</div>
</div>

View File

@@ -70,4 +70,5 @@ export interface IsoSignpostProps {
export interface IsometricSceneProps {
ariaLabel: string;
contacts: Array<{ id: FooterContactId; label: string; href: string }>;
}

View File

@@ -1,4 +1,4 @@
import type { ContactItem } from './IsoTypes';
import IsometricScene from './IsometricScene';
import {
MailIcon,
@@ -8,45 +8,16 @@ import {
TelegramIcon,
} from './IsoElements';
import styles from './IsometricFooter.module.css';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import { FooterInteraction } from './FooterInteraction';
export const contacts: ContactItem[] = [
{
id: 'phone',
name: 'Phone',
label: '09388263077',
href: 'tel:09388263077',
Icon: PhoneIcon,
},
{
id: 'linkedin',
name: 'LinkedIn',
label: '@amiirkhl',
href: 'https://linkedin.com/in/amiirkhl',
Icon: LinkedInIcon,
},
{
id: 'github',
name: 'GitHub',
label: 'git.amiirkhl.ir',
href: 'https://git.amiirkhl.ir',
Icon: GitHubIcon,
},
{
id: 'telegram',
name: 'Telegram',
label: '@amiirkhl',
href: 'https://t.me/amiirkhl',
Icon: TelegramIcon,
},
];
const contactIcons = { phone: PhoneIcon, linkedin: LinkedInIcon, github: GitHubIcon, telegram: TelegramIcon };
export default function IsometricFooter({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].footer;
const localizedContacts = contacts.map((contact) => ({
export default function IsometricFooter({ portfolio }: PortfolioProps) {
const copy = portfolio.messages.footer;
const localizedContacts = portfolio.profile.contacts.filter(contact => contact.href).map((contact) => ({
...contact,
name: copy[contact.id],
name: copy[contact.id], Icon: contactIcons[contact.id],
}));
return (
<FooterInteraction className={styles.footerContainer}>
@@ -61,9 +32,9 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
</p>
{/* Email Pill Button */}
<div className={styles.emailWrapper} data-reveal="scale">
{portfolio.profile.email && <div className={styles.emailWrapper} data-reveal="scale">
<a
href="mailto:amirhosseinkhalili901@gmail.com"
href={`mailto:${portfolio.profile.email}`}
className={styles.emailPill}
aria-label={copy.emailLabel}
>
@@ -71,15 +42,15 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
<MailIcon size={16} color="currentColor" />
</span>
<span className={styles.emailText} dir="ltr">
amirhosseinkhalili901@gmail.com
{portfolio.profile.email}
</span>
</a>
</div>
</div>}
</header>
{/* 3D Isometric SVG Scene */}
<div className={styles.sceneContainer} data-footer-reveal>
<IsometricScene ariaLabel={copy.sceneLabel} />
<IsometricScene ariaLabel={copy.sceneLabel} contacts={portfolio.profile.contacts} />
</div>
{/* Responsive Synchronized Contact Cards Section */}

View File

@@ -259,6 +259,7 @@ function CircuitConduits() {
*/
export default function IsometricScene({
ariaLabel,
contacts,
}: IsometricSceneProps) {
return (
<svg
@@ -300,56 +301,14 @@ export default function IsometricScene({
</g>
{/* 7. The 4 Interactive Isometric Signposts rotated in 3D */}
<IsoSignpost
id="phone"
x={280}
y={360}
postHeight={48}
facing="right"
backdropDirection="right-top"
icon={<PhoneIcon />}
label="09388263077"
href="tel:09388263077"
/>
<IsoSignpost
id="linkedin"
x={440}
y={360}
postHeight={48}
facing="right"
backdropDirection="right-top"
icon={<LinkedInIcon />}
label="@amiirkhl"
href="https://linkedin.com/in/amiirkhl"
external
/>
<IsoSignpost
id="github"
x={760}
y={360}
postHeight={48}
facing="left"
backdropDirection="left-top"
icon={<GitHubIcon />}
label="git.amiirkhl.ir"
href="https://git.amiirkhl.ir"
external
/>
<IsoSignpost
id="telegram"
x={920}
y={360}
postHeight={48}
facing="left"
backdropDirection="left-top"
icon={<TelegramIcon />}
label="@amiirkhl"
href="https://t.me/amiirkhl"
external
/>
{contacts.filter(contact => contact.href).map(contact => {
const positions = { phone: 280, linkedin: 440, github: 760, telegram: 920 };
const icons = { phone: <PhoneIcon />, linkedin: <LinkedInIcon />, github: <GitHubIcon />, telegram: <TelegramIcon /> };
const right = contact.id === 'phone' || contact.id === 'linkedin';
return <IsoSignpost key={contact.id} id={contact.id} x={positions[contact.id]} y={360} postHeight={48}
facing={right ? 'right' : 'left'} backdropDirection={right ? 'right-top' : 'left-top'}
icon={icons[contact.id]} label={contact.label} href={contact.href} external={!contact.href.startsWith('tel:')} />;
})}
</svg>
);
}

View File

@@ -1,13 +1,14 @@
import { formatMessage } from '@/content/contracts';
import Image from 'next/image';
import { ExternalLink } from 'lucide-react';
import { getMilestones } from '@/data/milestones';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './MilestonesSection.module.css';
export const MilestonesSection = ({ locale }: { locale: Locale }) => {
const isRtl = locale === 'fa';
const milestones = getMilestones(locale);
const copy = messagesByLocale[locale].milestones;
export const MilestonesSection = ({ portfolio }: PortfolioProps) => {
const { locale } = portfolio;
const isRtl = portfolio.direction === 'rtl';
const milestones = portfolio.milestones;
const copy = portfolio.messages.milestones;
return (
<section id="milestones" className={styles.milestonesSection}>
@@ -57,7 +58,7 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
className={styles.viewCertLink}
target="_blank"
rel="noopener noreferrer"
aria-label={copy.viewCertificateLabel(item.title)}
aria-label={formatMessage(copy.viewCertificateLabel, { title: item.title }, locale)}
>
{isRtl && (
<ExternalLink size={20} strokeWidth={2.25} aria-hidden="true" />

View File

@@ -1,10 +1,12 @@
'use client';
import { formatMessage } from '@/content/contracts';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
import { getProjectPath, getProjects } from '@/data/projects';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { projectPath } from '@/content/contracts';
import type { PortfolioProps } from '@/server/content';
import { ProjectThumbnail } from './ProjectThumbnail';
import styles from './ProjectsSection.module.css';
@@ -24,10 +26,11 @@ const setLogicalScrollLeft = (
viewport.scrollTo({ left: isRtl ? -value : value, behavior });
};
export const ProjectsSection = ({ locale }: { locale: Locale }) => {
const isRtl = locale === 'fa';
const projects = getProjects(locale);
const copy = messagesByLocale[locale].projects;
export const ProjectsSection = ({ portfolio }: PortfolioProps) => {
const { locale } = portfolio;
const isRtl = portfolio.direction === 'rtl';
const projects = portfolio.projects;
const copy = portfolio.messages.projects;
const viewportRef = useRef<HTMLDivElement>(null);
const dragState = useRef({
active: false,
@@ -380,7 +383,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
target="_blank"
rel="noopener noreferrer"
className={styles.titleLiveLink}
aria-label={copy.liveProjectLabel(project.title)}
aria-label={formatMessage(copy.liveProjectLabel, { title: project.title }, locale)}
>
<ArrowUpRight
size={19}
@@ -403,7 +406,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
target="_blank"
rel="noopener noreferrer"
className={styles.titleLiveLink}
aria-label={copy.liveProjectLabel(project.title)}
aria-label={formatMessage(copy.liveProjectLabel, { title: project.title }, locale)}
>
<ArrowUpRight
size={19}
@@ -433,7 +436,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
<div className={styles.cardFooter}>
<Link
href={getProjectPath(locale, project.id)}
href={projectPath(locale, project.id, portfolio.defaultLanguage)}
scroll={false}
className={styles.caseStudyLink}
onClick={(event) => {

View File

@@ -6,13 +6,13 @@ import {
Rocket,
} from 'lucide-react';
import { DeferredAboutRiverScene } from './scenes/DeferredAboutRiverScene';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './RiverJourney.module.css';
const stepIcons = [Palette, PanelsTopLeft, GraduationCap, Code2, Rocket];
export const RiverJourney = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].about;
export const RiverJourney = ({ portfolio }: PortfolioProps) => {
const copy = portfolio.messages.about;
return (
<section id="about" className={styles.journeySection} aria-labelledby="about-title">
@@ -22,7 +22,7 @@ export const RiverJourney = ({ locale }: { locale: Locale }) => {
<span className={styles.eyebrow}>{copy.eyebrow}</span>
<h2
id="about-title"
className={`${styles.heading} ${locale === 'fa' ? 'font-display' : ''}`}
className={`${styles.heading} ${portfolio.direction === 'rtl' ? 'font-display' : ''}`}
>
{copy.title}
</h2>
@@ -36,7 +36,7 @@ export const RiverJourney = ({ locale }: { locale: Locale }) => {
data-reveal-group
>
{copy.steps.map((step, index) => {
const StepIcon = stepIcons[index];
const StepIcon = stepIcons[index % stepIcons.length];
return (
<li key={step.title} className={styles.stepCard} data-reveal="soft">

View File

@@ -7,7 +7,7 @@ import {
HeroIllustrationMoon,
} from './HeroIllustrations';
import styles from './ScenicHeroJourney.module.css';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
function HeroBackdrop() {
return (
@@ -20,17 +20,17 @@ function HeroBackdrop() {
);
}
export const ScenicHeroJourney = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale];
export const ScenicHeroJourney = ({ portfolio }: PortfolioProps) => {
const copy = portfolio.messages;
return (
<div className={styles.scenicContainer} data-looping-motion>
<section className={styles.heroStage} aria-label={copy.hero.sectionLabel}>
{portfolio.sections.hero.visible && <section className={styles.heroStage} aria-label={copy.hero.sectionLabel}>
<HeroBackdrop />
<HeroSection locale={locale} />
</section>
<HeroSection portfolio={portfolio} />
</section>}
<RiverJourney locale={locale} />
{portfolio.sections.about.visible && <RiverJourney portfolio={portfolio} />}
</div>
);
};

View File

@@ -3,20 +3,15 @@
import { useState } from 'react';
import Image from 'next/image';
import { Code, BarChart2, Wand2 } from 'lucide-react';
import {
skillsByDiscipline,
filterTabs,
FilterTab,
} from '@/data/skills';
import { messagesByLocale, type Locale } from '@/i18n/content';
import type { PortfolioProps } from '@/server/content';
import styles from './SkillsSection.module.css';
export const SkillsSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].skills;
const [activeFilter, setActiveFilter] = useState<FilterTab['id']>('software-engineering');
const activeDiscipline = skillsByDiscipline[activeFilter];
export const SkillsSection = ({ portfolio }: PortfolioProps) => {
const copy = portfolio.messages.skills;
const [activeFilter, setActiveFilter] = useState(portfolio.skills[0]?.id);
const activeDiscipline = portfolio.skills.find(item => item.id === activeFilter) ?? portfolio.skills[0];
const getFilterIcon = (id: FilterTab['id']) => {
const getFilterIcon = (id: string) => {
switch (id) {
case 'software-engineering':
return <Code size={18} />;
@@ -24,6 +19,8 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
return <BarChart2 size={18} />;
case 'design-ux':
return <Wand2 size={18} />;
default:
return <Code size={18} />;
}
};
@@ -113,7 +110,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
aria-label={copy.filterLabel}
data-reveal="soft"
>
{filterTabs.map((tab) => {
{portfolio.skills.map((tab) => {
const isActive = activeFilter === tab.id;
return (
<button
@@ -126,7 +123,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
} ${styles.filterButton}`}
>
{getFilterIcon(tab.id)}
<span>{copy.filterLabels[tab.id]}</span>
<span>{tab.label}</span>
</button>
);
})}

15
src/server/public.ts Normal file
View File

@@ -0,0 +1,15 @@
import 'server-only';
import { cache } from 'react';
import { connection } from 'next/server';
import { notFound } from 'next/navigation';
import { getDatabase } from './db';
import { ContentError, readContent, resolvePublicRoute } from './content';
export const readPublicSnapshot = cache(async () => {
await connection();
return readContent(getDatabase());
});
export async function getPublicRoute(segments: string[] = []) {
try { return resolvePublicRoute(await readPublicSnapshot(), segments); }
catch (error) { if (error instanceof ContentError && error.status === 404) notFound(); throw error; }
}