feat: render portfolio routes from live localized database content
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function PersianHome() {
|
||||
return null;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
|
||||
|
||||
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <CaseStudyDrawer locale="fa">{children}</CaseStudyDrawer>;
|
||||
}
|
||||
5
src/app/(localized)/[locale]/layout.tsx
Normal file
5
src/app/(localized)/[locale]/layout.tsx
Normal 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>;
|
||||
}
|
||||
5
src/app/(localized)/[locale]/page.tsx
Normal file
5
src/app/(localized)/[locale]/page.tsx
Normal 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; }
|
||||
10
src/app/(localized)/[locale]/projects/[projectId]/page.tsx
Normal file
10
src/app/(localized)/[locale]/projects/[projectId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
6
src/app/(localized)/[locale]/projects/layout.tsx
Normal file
6
src/app/(localized)/[locale]/projects/layout.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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
17
src/app/ProjectPage.tsx
Normal 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
21
src/app/PublicLayout.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,4 +70,5 @@ export interface IsoSignpostProps {
|
||||
|
||||
export interface IsometricSceneProps {
|
||||
ariaLabel: string;
|
||||
contacts: Array<{ id: FooterContactId; label: string; href: string }>;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
15
src/server/public.ts
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user