feat: render portfolio routes from live localized database content
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
serverExternalPackages: ['better-sqlite3'],
|
||||||
|
outputFileTracingIncludes: {
|
||||||
|
'/*': ['./migrations/**/*'],
|
||||||
|
},
|
||||||
|
outputFileTracingExcludes: {
|
||||||
|
'*': ['./data/**/*', './backups/**/*', './**/auth-secret', './**/*.sqlite*', './.env*'],
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
globalNotFound: true,
|
globalNotFound: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import { PublicLayout } from '@/app/PublicLayout';
|
||||||
import { SiteRoot } from '@/app/SiteRoot';
|
export default async function Layout({ children, }: { children: React.ReactNode; }) {
|
||||||
import { PortfolioPage } from '@/app/PortfolioPage';
|
const locale = undefined;
|
||||||
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
|
return <PublicLayout locale={locale}>{children}</PublicLayout>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export default function Home() {
|
import { publicMetadata } from '@/app/PublicLayout';
|
||||||
return null;
|
export async function generateMetadata() {
|
||||||
|
return publicMetadata();
|
||||||
}
|
}
|
||||||
|
export default function Home() { return null; }
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import { ProjectPage, projectMetadata } from '@/app/ProjectPage';
|
||||||
import { notFound } from 'next/navigation';
|
type Props = { params: Promise<{ projectId: string; }> };
|
||||||
import { CaseStudyContent } from '@/components/CaseStudyContent';
|
export async function generateMetadata({ params }: Props) {
|
||||||
import { getAdjacentProjects, getProject, projectIds } from '@/data/projects';
|
const values = await params;
|
||||||
|
return projectMetadata(values.projectId);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
export default async function Page({ params }: Props) {
|
||||||
export default async function ProjectPage({ params }: Props) {
|
const values = await params;
|
||||||
const { projectId } = await params;
|
return <ProjectPage id={values.projectId} />;
|
||||||
const project = getProject('en', projectId);
|
|
||||||
const adjacent = getAdjacentProjects('en', projectId);
|
|
||||||
if (!project || !adjacent) notFound();
|
|
||||||
return <CaseStudyContent project={project} locale="en" {...adjacent} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
|
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
|
||||||
|
import { getPublicRoute } from '@/server/public';
|
||||||
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
export default async function ProjectsLayout({ children, }: { children: React.ReactNode; }) {
|
||||||
return <CaseStudyDrawer locale="en">{children}</CaseStudyDrawer>;
|
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 { MilestonesSection } from '@/components/MilestonesSection';
|
||||||
import { FooterSection } from '@/components/FooterSection';
|
import { FooterSection } from '@/components/FooterSection';
|
||||||
import { MotionController } from '@/components/MotionController';
|
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 (
|
return (
|
||||||
<main className="desktop-canvas" data-locale={locale}>
|
<main className="desktop-canvas" data-locale={portfolio.locale}>
|
||||||
<MotionController />
|
<MotionController />
|
||||||
<ScenicHeroJourney locale={locale} />
|
<ScenicHeroJourney portfolio={portfolio} />
|
||||||
<ProjectsSection locale={locale} />
|
{portfolio.sections.projects.visible && portfolio.projects.length > 0 && <ProjectsSection portfolio={portfolio} />}
|
||||||
<ExperienceSection locale={locale} />
|
{portfolio.sections.experience.visible && portfolio.experience.length > 0 && <ExperienceSection portfolio={portfolio} />}
|
||||||
<SkillsSection locale={locale} />
|
{portfolio.sections.skills.visible && portfolio.skills.length > 0 && <SkillsSection portfolio={portfolio} />}
|
||||||
<EducationSection locale={locale} />
|
{portfolio.sections.education.visible && portfolio.education.length > 0 && <EducationSection portfolio={portfolio} />}
|
||||||
<MilestonesSection locale={locale} />
|
{portfolio.sections.milestones.visible && portfolio.milestones.length > 0 && <MilestonesSection portfolio={portfolio} />}
|
||||||
<FooterSection locale={locale} />
|
{portfolio.sections.footer.visible && <FooterSection portfolio={portfolio} />}
|
||||||
</main>
|
</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 type { ReactNode } from 'react';
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import { ThemeProvider } from '@/context/ThemeContext';
|
import { ThemeProvider } from '@/context/ThemeContext';
|
||||||
import type { Locale } from '@/i18n/content';
|
|
||||||
|
|
||||||
const themeScript = `
|
const themeScript = `
|
||||||
try {
|
try {
|
||||||
@@ -43,14 +42,16 @@ const peyda = localFont({
|
|||||||
export function SiteRoot({
|
export function SiteRoot({
|
||||||
children,
|
children,
|
||||||
locale,
|
locale,
|
||||||
|
direction = 'ltr',
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
locale: Locale;
|
locale: string;
|
||||||
|
direction?: 'rtl' | 'ltr';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
dir={direction}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -1,72 +1,20 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import localFont from 'next/font/local';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
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/globals.css';
|
||||||
import styles from './global-not-found.module.css';
|
import styles from './global-not-found.module.css';
|
||||||
|
|
||||||
const themeAndLocaleScript = `
|
export default async function GlobalNotFound() {
|
||||||
try {
|
const snapshot = await readPublicSnapshot();
|
||||||
const root = document.documentElement;
|
const portfolio = publicContent(snapshot, snapshot.settings.defaultLanguage);
|
||||||
const isPersian = /^\\/fa(?:\\/|$)/.test(window.location.pathname);
|
const copy = portfolio.messages.notFound;
|
||||||
root.lang = isPersian ? 'fa' : 'en';
|
return <SiteRoot locale={portfolio.locale} direction={portfolio.direction}>
|
||||||
root.dir = isPersian ? 'rtl' : 'ltr';
|
<main className={styles.page}><section className={styles.content} aria-labelledby="not-found-title">
|
||||||
const savedTheme = localStorage.getItem('portfolio-theme');
|
<p className={styles.code} aria-hidden="true">{copy.code}</p><span className={styles.divider} aria-hidden="true" />
|
||||||
root.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
|
<h1 id="not-found-title">{copy.title}</h1><p className={styles.description}>{copy.description}</p>
|
||||||
} catch {}
|
<Link href="/" className={styles.homeLink}>{portfolio.direction === 'rtl' ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}<span>{copy.backHome}</span></Link>
|
||||||
`;
|
</section></main>
|
||||||
|
</SiteRoot>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PublicPortfolio } from '@/server/content';
|
||||||
import { getProjectPath, type Project } from '@/data/projects';
|
import type { Project } from '@/data/projects';
|
||||||
|
import { projectPath } from '@/content/contracts';
|
||||||
import { CaseStudyGallery } from './CaseStudyGallery';
|
import { CaseStudyGallery } from './CaseStudyGallery';
|
||||||
import styles from './CaseStudyDrawer.module.css';
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
export function CaseStudyContent({
|
export function CaseStudyContent({
|
||||||
project,
|
project,
|
||||||
locale,
|
portfolio,
|
||||||
previous,
|
previous,
|
||||||
next,
|
next,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
locale: Locale;
|
portfolio: PublicPortfolio;
|
||||||
previous: Project;
|
previous: Project;
|
||||||
next: Project;
|
next: Project;
|
||||||
}) {
|
}) {
|
||||||
const copy = messagesByLocale[locale].projects;
|
const { locale } = portfolio;
|
||||||
|
const copy = portfolio.messages.projects;
|
||||||
const caseStudy = project.caseStudy;
|
const caseStudy = project.caseStudy;
|
||||||
const isRtl = locale === 'fa';
|
const isRtl = portfolio.direction === 'rtl';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.content}>
|
<article className={styles.content}>
|
||||||
@@ -29,7 +31,7 @@ export function CaseStudyContent({
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<CaseStudyGallery key={project.id} items={caseStudy.gallery} locale={locale} />
|
<CaseStudyGallery key={project.id} items={caseStudy.gallery} portfolio={portfolio} />
|
||||||
|
|
||||||
<div className={styles.overview}>
|
<div className={styles.overview}>
|
||||||
<p id="case-study-introduction" className={styles.introduction}>{caseStudy.introduction}</p>
|
<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}>
|
<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" />}
|
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
|
||||||
<span><small>{copy.previousCaseStudy}</small><strong>{previous.title}</strong></span>
|
<span><small>{copy.previousCaseStudy}</small><strong>{previous.title}</strong></span>
|
||||||
</Link>
|
</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>
|
<span><small>{copy.nextCaseStudy}</small><strong>{next.title}</strong></span>
|
||||||
{isRtl ? <ArrowLeft aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
|
{isRtl ? <ArrowLeft aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
|
import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PublicPortfolio } from '@/server/content';
|
||||||
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
||||||
import { CaseStudyShare } from './CaseStudyShare';
|
import { CaseStudyShare } from './CaseStudyShare';
|
||||||
import { useDrawerDrag } from './useDrawerDrag';
|
import { useDrawerDrag } from './useDrawerDrag';
|
||||||
import styles from './CaseStudyDrawer.module.css';
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) {
|
export function CaseStudyDrawer({ children, portfolio }: { children: ReactNode; portfolio: PublicPortfolio }) {
|
||||||
const copy = messagesByLocale[locale].projects;
|
const copy = portfolio.messages.projects;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const dismissRoute = useDismissCaseStudy();
|
const dismissRoute = useDismissCaseStudy();
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -127,7 +127,7 @@ export function CaseStudyDrawer({ children, locale }: { children: ReactNode; loc
|
|||||||
</div>
|
</div>
|
||||||
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
|
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<CaseStudyShare locale={locale} />
|
<CaseStudyShare portfolio={portfolio} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconButton}
|
className={styles.iconButton}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatMessage } from '@/content/contracts';
|
||||||
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
@@ -15,18 +17,19 @@ import {
|
|||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { CaseStudyGalleryItem } from '@/data/projects';
|
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';
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
export function CaseStudyGallery({
|
export function CaseStudyGallery({
|
||||||
items,
|
items,
|
||||||
locale,
|
portfolio,
|
||||||
}: {
|
}: {
|
||||||
items: CaseStudyGalleryItem[];
|
items: CaseStudyGalleryItem[];
|
||||||
locale: Locale;
|
portfolio: PublicPortfolio;
|
||||||
}) {
|
}) {
|
||||||
const copy = messagesByLocale[locale].projects;
|
const { locale } = portfolio;
|
||||||
const isRtl = locale === 'fa';
|
const copy = portfolio.messages.projects;
|
||||||
|
const isRtl = portfolio.direction === 'rtl';
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [viewerOpen, setViewerOpen] = useState(false);
|
const [viewerOpen, setViewerOpen] = useState(false);
|
||||||
const viewerRef = useRef<HTMLDialogElement>(null);
|
const viewerRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -142,7 +145,7 @@ export function CaseStudyGallery({
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.galleryStage}
|
className={styles.galleryStage}
|
||||||
onClick={() => setViewerOpen(true)}
|
onClick={() => setViewerOpen(true)}
|
||||||
aria-label={copy.openGalleryImage(selected.caption)}
|
aria-label={formatMessage(copy.openGalleryImage, { caption: selected.caption }, locale)}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -161,7 +164,7 @@ export function CaseStudyGallery({
|
|||||||
</button>
|
</button>
|
||||||
<figcaption className={styles.galleryCaption}>
|
<figcaption className={styles.galleryCaption}>
|
||||||
<span>{selected.caption}</span>
|
<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>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -175,7 +178,7 @@ export function CaseStudyGallery({
|
|||||||
className={styles.thumbnailButton}
|
className={styles.thumbnailButton}
|
||||||
data-selected={index === selectedIndex}
|
data-selected={index === selectedIndex}
|
||||||
onClick={() => selectImage(index)}
|
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}
|
aria-pressed={index === selectedIndex}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -221,7 +224,7 @@ export function CaseStudyGallery({
|
|||||||
<header className={styles.viewerHeader}>
|
<header className={styles.viewerHeader}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selected.caption}</strong>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
ref={closeRef}
|
ref={closeRef}
|
||||||
@@ -276,7 +279,7 @@ export function CaseStudyGallery({
|
|||||||
className={styles.viewerThumbnail}
|
className={styles.viewerThumbnail}
|
||||||
data-selected={index === selectedIndex}
|
data-selected={index === selectedIndex}
|
||||||
onClick={() => selectImage(index)}
|
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}
|
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} />
|
<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,
|
useState,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import styles from './CaseStudyDrawer.module.css';
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
type CopyState = 'idle' | 'copied' | 'error';
|
type CopyState = 'idle' | 'copied' | 'error';
|
||||||
|
|
||||||
export function CaseStudyShare({ locale }: { locale: Locale }) {
|
export function CaseStudyShare({ portfolio }: PortfolioProps) {
|
||||||
const copy = messagesByLocale[locale].projects;
|
const copy = portfolio.messages.projects;
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { getEducation } from '@/data/education';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
||||||
import styles from './EducationSection.module.css';
|
import styles from './EducationSection.module.css';
|
||||||
|
|
||||||
export const EducationSection = ({ locale }: { locale: Locale }) => {
|
export const EducationSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const education = getEducation(locale);
|
const copy = portfolio.messages.education;
|
||||||
const copy = messagesByLocale[locale].education;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="education" className={styles.educationSection}>
|
<section id="education" className={styles.educationSection}>
|
||||||
@@ -18,7 +16,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Degree Card */}
|
{/* 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.crestCol} data-reveal="image">
|
||||||
<div className={styles.crestContainer}>
|
<div className={styles.crestContainer}>
|
||||||
<Image
|
<Image
|
||||||
@@ -37,7 +35,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
|
|||||||
<div className={styles.date}>{education.date}</div>
|
<div className={styles.date}>{education.date}</div>
|
||||||
<p className={styles.description}>{education.description}</p>
|
<p className={styles.description}>{education.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { formatMessage } from '@/content/contracts';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { getExperiences } from '@/data/experience';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
||||||
import styles from './ExperienceSection.module.css';
|
import styles from './ExperienceSection.module.css';
|
||||||
|
|
||||||
export const ExperienceSection = ({ locale }: { locale: Locale }) => {
|
export const ExperienceSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const experiences = getExperiences(locale);
|
const { locale } = portfolio;
|
||||||
const copy = messagesByLocale[locale].experience;
|
const experiences = portfolio.experience;
|
||||||
|
const copy = portfolio.messages.experience;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="experience" className={styles.experienceSection}>
|
<section id="experience" className={styles.experienceSection}>
|
||||||
@@ -68,7 +69,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
|
|||||||
<div className={styles.logoContainer}>
|
<div className={styles.logoContainer}>
|
||||||
<Image
|
<Image
|
||||||
src={item.logo.src}
|
src={item.logo.src}
|
||||||
alt={copy.logoAlt(item.company)}
|
alt={formatMessage(copy.logoAlt, { company: item.company }, locale)}
|
||||||
fill
|
fill
|
||||||
sizes="72px"
|
sizes="72px"
|
||||||
className={styles.logoImage}
|
className={styles.logoImage}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import IsometricFooter from './IsometricFooter/IsometricFooter';
|
import IsometricFooter from './IsometricFooter/IsometricFooter';
|
||||||
import type { Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import styles from './FooterSection.module.css';
|
import styles from './FooterSection.module.css';
|
||||||
|
|
||||||
export const FooterSection = ({ locale }: { locale: Locale }) => {
|
export const FooterSection = ({ portfolio }: PortfolioProps) => {
|
||||||
return (
|
return (
|
||||||
<footer id="footer" className={styles.footerSection}>
|
<footer id="footer" className={styles.footerSection}>
|
||||||
<IsometricFooter locale={locale} />
|
<IsometricFooter portfolio={portfolio} />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import { portfolioPath } from '@/content/contracts';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Check, Languages, Moon, Sun } from 'lucide-react';
|
import { Check, Languages, Moon, Sun } from 'lucide-react';
|
||||||
import { useTheme } from '@/context/ThemeContext';
|
import { useTheme } from '@/context/ThemeContext';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import styles from './HeroSection.module.css';
|
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 { theme, toggleTheme } = useTheme();
|
||||||
const copy = messagesByLocale[locale].hero;
|
const copy = portfolio.messages.hero;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(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" />
|
<Languages size={23} strokeWidth={1.9} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<div id="language-menu" className={styles.languageMenu} role="menu" aria-label={copy.languageMenuLabel} data-open={isOpen}>
|
<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}>
|
{portfolio.languages.map(language => <Link key={language.code}
|
||||||
<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>
|
href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
|
||||||
{locale === 'en' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
|
aria-current={locale === language.code ? 'page' : undefined} className={styles.languageOption}>
|
||||||
</Link>
|
<span className={styles.languageOptionLabel} lang={language.code} dir={language.direction}>{language.name}</span>
|
||||||
<Link href="/fa" hrefLang="fa" role="menuitem" aria-current={locale === 'fa' ? 'page' : undefined} className={styles.languageOption}>
|
{locale === language.code && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
|
||||||
{locale === 'fa' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
|
</Link>)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
|
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { HeroControls } from './HeroControls';
|
import { HeroControls } from './HeroControls';
|
||||||
import styles from './HeroSection.module.css';
|
import styles from './HeroSection.module.css';
|
||||||
|
|
||||||
export const HeroSection = ({ locale }: { locale: Locale }) => {
|
export const HeroSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const copy = messagesByLocale[locale].hero;
|
const copy = portfolio.messages.hero;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.heroContentWrapper}>
|
<div className={styles.heroContentWrapper}>
|
||||||
<HeroControls locale={locale} />
|
<HeroControls portfolio={portfolio} />
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<div className={styles.avatarWrapper}>
|
<div className={styles.avatarWrapper}>
|
||||||
<div className={styles.avatarRingOuter}>
|
<div className={styles.avatarRingOuter}>
|
||||||
<div className={styles.avatarContainer}>
|
<div className={styles.avatarContainer}>
|
||||||
<Image
|
<Image
|
||||||
src="/images/avatar.png"
|
src={portfolio.profile.avatar.src}
|
||||||
alt={copy.avatarAlt}
|
alt={copy.avatarAlt}
|
||||||
fill
|
fill
|
||||||
sizes="192px"
|
sizes="192px"
|
||||||
@@ -33,18 +33,24 @@ export const HeroSection = ({ locale }: { locale: Locale }) => {
|
|||||||
<p className={styles.heroSubtitle}>{copy.subtitle}</p>
|
<p className={styles.heroSubtitle}>{copy.subtitle}</p>
|
||||||
|
|
||||||
<div className={styles.actionButtons}>
|
<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" />
|
<BookOpenText size={17} strokeWidth={2} aria-hidden="true" />
|
||||||
{copy.blog}
|
{copy.blog}
|
||||||
</button>
|
</a> : <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.blogLabel} title={copy.comingSoon} disabled>
|
||||||
<button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.coursesLabel} 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" />
|
<GraduationCap size={18} strokeWidth={2} aria-hidden="true" />
|
||||||
{copy.courses}
|
{copy.courses}
|
||||||
</button>
|
</a> : <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.coursesLabel} title={copy.comingSoon} disabled>
|
||||||
<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}>
|
<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" />
|
<MessageCircle size={17} strokeWidth={2} aria-hidden="true" />
|
||||||
{copy.contact}
|
{copy.contact}
|
||||||
</a>
|
</a>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ export interface IsoSignpostProps {
|
|||||||
|
|
||||||
export interface IsometricSceneProps {
|
export interface IsometricSceneProps {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
contacts: Array<{ id: FooterContactId; label: string; href: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ContactItem } from './IsoTypes';
|
|
||||||
import IsometricScene from './IsometricScene';
|
import IsometricScene from './IsometricScene';
|
||||||
import {
|
import {
|
||||||
MailIcon,
|
MailIcon,
|
||||||
@@ -8,45 +8,16 @@ import {
|
|||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from './IsoElements';
|
} from './IsoElements';
|
||||||
import styles from './IsometricFooter.module.css';
|
import styles from './IsometricFooter.module.css';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { FooterInteraction } from './FooterInteraction';
|
import { FooterInteraction } from './FooterInteraction';
|
||||||
|
|
||||||
export const contacts: ContactItem[] = [
|
const contactIcons = { phone: PhoneIcon, linkedin: LinkedInIcon, github: GitHubIcon, telegram: TelegramIcon };
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function IsometricFooter({ locale }: { locale: Locale }) {
|
export default function IsometricFooter({ portfolio }: PortfolioProps) {
|
||||||
const copy = messagesByLocale[locale].footer;
|
const copy = portfolio.messages.footer;
|
||||||
const localizedContacts = contacts.map((contact) => ({
|
const localizedContacts = portfolio.profile.contacts.filter(contact => contact.href).map((contact) => ({
|
||||||
...contact,
|
...contact,
|
||||||
name: copy[contact.id],
|
name: copy[contact.id], Icon: contactIcons[contact.id],
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<FooterInteraction className={styles.footerContainer}>
|
<FooterInteraction className={styles.footerContainer}>
|
||||||
@@ -61,9 +32,9 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Email Pill Button */}
|
{/* Email Pill Button */}
|
||||||
<div className={styles.emailWrapper} data-reveal="scale">
|
{portfolio.profile.email && <div className={styles.emailWrapper} data-reveal="scale">
|
||||||
<a
|
<a
|
||||||
href="mailto:amirhosseinkhalili901@gmail.com"
|
href={`mailto:${portfolio.profile.email}`}
|
||||||
className={styles.emailPill}
|
className={styles.emailPill}
|
||||||
aria-label={copy.emailLabel}
|
aria-label={copy.emailLabel}
|
||||||
>
|
>
|
||||||
@@ -71,15 +42,15 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
|
|||||||
<MailIcon size={16} color="currentColor" />
|
<MailIcon size={16} color="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.emailText} dir="ltr">
|
<span className={styles.emailText} dir="ltr">
|
||||||
amirhosseinkhalili901@gmail.com
|
{portfolio.profile.email}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 3D Isometric SVG Scene */}
|
{/* 3D Isometric SVG Scene */}
|
||||||
<div className={styles.sceneContainer} data-footer-reveal>
|
<div className={styles.sceneContainer} data-footer-reveal>
|
||||||
<IsometricScene ariaLabel={copy.sceneLabel} />
|
<IsometricScene ariaLabel={copy.sceneLabel} contacts={portfolio.profile.contacts} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Responsive Synchronized Contact Cards Section */}
|
{/* Responsive Synchronized Contact Cards Section */}
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ function CircuitConduits() {
|
|||||||
*/
|
*/
|
||||||
export default function IsometricScene({
|
export default function IsometricScene({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
contacts,
|
||||||
}: IsometricSceneProps) {
|
}: IsometricSceneProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -300,56 +301,14 @@ export default function IsometricScene({
|
|||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* 7. The 4 Interactive Isometric Signposts rotated in 3D */}
|
{/* 7. The 4 Interactive Isometric Signposts rotated in 3D */}
|
||||||
<IsoSignpost
|
{contacts.filter(contact => contact.href).map(contact => {
|
||||||
id="phone"
|
const positions = { phone: 280, linkedin: 440, github: 760, telegram: 920 };
|
||||||
x={280}
|
const icons = { phone: <PhoneIcon />, linkedin: <LinkedInIcon />, github: <GitHubIcon />, telegram: <TelegramIcon /> };
|
||||||
y={360}
|
const right = contact.id === 'phone' || contact.id === 'linkedin';
|
||||||
postHeight={48}
|
return <IsoSignpost key={contact.id} id={contact.id} x={positions[contact.id]} y={360} postHeight={48}
|
||||||
facing="right"
|
facing={right ? 'right' : 'left'} backdropDirection={right ? 'right-top' : 'left-top'}
|
||||||
backdropDirection="right-top"
|
icon={icons[contact.id]} label={contact.label} href={contact.href} external={!contact.href.startsWith('tel:')} />;
|
||||||
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
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { formatMessage } from '@/content/contracts';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { getMilestones } from '@/data/milestones';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
||||||
import styles from './MilestonesSection.module.css';
|
import styles from './MilestonesSection.module.css';
|
||||||
|
|
||||||
export const MilestonesSection = ({ locale }: { locale: Locale }) => {
|
export const MilestonesSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const isRtl = locale === 'fa';
|
const { locale } = portfolio;
|
||||||
const milestones = getMilestones(locale);
|
const isRtl = portfolio.direction === 'rtl';
|
||||||
const copy = messagesByLocale[locale].milestones;
|
const milestones = portfolio.milestones;
|
||||||
|
const copy = portfolio.messages.milestones;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="milestones" className={styles.milestonesSection}>
|
<section id="milestones" className={styles.milestonesSection}>
|
||||||
@@ -57,7 +58,7 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
|
|||||||
className={styles.viewCertLink}
|
className={styles.viewCertLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={copy.viewCertificateLabel(item.title)}
|
aria-label={formatMessage(copy.viewCertificateLabel, { title: item.title }, locale)}
|
||||||
>
|
>
|
||||||
{isRtl && (
|
{isRtl && (
|
||||||
<ExternalLink size={20} strokeWidth={2.25} aria-hidden="true" />
|
<ExternalLink size={20} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatMessage } from '@/content/contracts';
|
||||||
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||||
import { getProjectPath, getProjects } from '@/data/projects';
|
import { projectPath } from '@/content/contracts';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import { ProjectThumbnail } from './ProjectThumbnail';
|
import { ProjectThumbnail } from './ProjectThumbnail';
|
||||||
import styles from './ProjectsSection.module.css';
|
import styles from './ProjectsSection.module.css';
|
||||||
|
|
||||||
@@ -24,10 +26,11 @@ const setLogicalScrollLeft = (
|
|||||||
viewport.scrollTo({ left: isRtl ? -value : value, behavior });
|
viewport.scrollTo({ left: isRtl ? -value : value, behavior });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
export const ProjectsSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const isRtl = locale === 'fa';
|
const { locale } = portfolio;
|
||||||
const projects = getProjects(locale);
|
const isRtl = portfolio.direction === 'rtl';
|
||||||
const copy = messagesByLocale[locale].projects;
|
const projects = portfolio.projects;
|
||||||
|
const copy = portfolio.messages.projects;
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const dragState = useRef({
|
const dragState = useRef({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -380,7 +383,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.titleLiveLink}
|
className={styles.titleLiveLink}
|
||||||
aria-label={copy.liveProjectLabel(project.title)}
|
aria-label={formatMessage(copy.liveProjectLabel, { title: project.title }, locale)}
|
||||||
>
|
>
|
||||||
<ArrowUpRight
|
<ArrowUpRight
|
||||||
size={19}
|
size={19}
|
||||||
@@ -403,7 +406,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.titleLiveLink}
|
className={styles.titleLiveLink}
|
||||||
aria-label={copy.liveProjectLabel(project.title)}
|
aria-label={formatMessage(copy.liveProjectLabel, { title: project.title }, locale)}
|
||||||
>
|
>
|
||||||
<ArrowUpRight
|
<ArrowUpRight
|
||||||
size={19}
|
size={19}
|
||||||
@@ -433,7 +436,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
|
|
||||||
<div className={styles.cardFooter}>
|
<div className={styles.cardFooter}>
|
||||||
<Link
|
<Link
|
||||||
href={getProjectPath(locale, project.id)}
|
href={projectPath(locale, project.id, portfolio.defaultLanguage)}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
className={styles.caseStudyLink}
|
className={styles.caseStudyLink}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DeferredAboutRiverScene } from './scenes/DeferredAboutRiverScene';
|
import { DeferredAboutRiverScene } from './scenes/DeferredAboutRiverScene';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
import styles from './RiverJourney.module.css';
|
import styles from './RiverJourney.module.css';
|
||||||
|
|
||||||
const stepIcons = [Palette, PanelsTopLeft, GraduationCap, Code2, Rocket];
|
const stepIcons = [Palette, PanelsTopLeft, GraduationCap, Code2, Rocket];
|
||||||
|
|
||||||
export const RiverJourney = ({ locale }: { locale: Locale }) => {
|
export const RiverJourney = ({ portfolio }: PortfolioProps) => {
|
||||||
const copy = messagesByLocale[locale].about;
|
const copy = portfolio.messages.about;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className={styles.journeySection} aria-labelledby="about-title">
|
<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>
|
<span className={styles.eyebrow}>{copy.eyebrow}</span>
|
||||||
<h2
|
<h2
|
||||||
id="about-title"
|
id="about-title"
|
||||||
className={`${styles.heading} ${locale === 'fa' ? 'font-display' : ''}`}
|
className={`${styles.heading} ${portfolio.direction === 'rtl' ? 'font-display' : ''}`}
|
||||||
>
|
>
|
||||||
{copy.title}
|
{copy.title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -36,7 +36,7 @@ export const RiverJourney = ({ locale }: { locale: Locale }) => {
|
|||||||
data-reveal-group
|
data-reveal-group
|
||||||
>
|
>
|
||||||
{copy.steps.map((step, index) => {
|
{copy.steps.map((step, index) => {
|
||||||
const StepIcon = stepIcons[index];
|
const StepIcon = stepIcons[index % stepIcons.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={step.title} className={styles.stepCard} data-reveal="soft">
|
<li key={step.title} className={styles.stepCard} data-reveal="soft">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
HeroIllustrationMoon,
|
HeroIllustrationMoon,
|
||||||
} from './HeroIllustrations';
|
} from './HeroIllustrations';
|
||||||
import styles from './ScenicHeroJourney.module.css';
|
import styles from './ScenicHeroJourney.module.css';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
|
|
||||||
function HeroBackdrop() {
|
function HeroBackdrop() {
|
||||||
return (
|
return (
|
||||||
@@ -20,17 +20,17 @@ function HeroBackdrop() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScenicHeroJourney = ({ locale }: { locale: Locale }) => {
|
export const ScenicHeroJourney = ({ portfolio }: PortfolioProps) => {
|
||||||
const copy = messagesByLocale[locale];
|
const copy = portfolio.messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.scenicContainer} data-looping-motion>
|
<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 />
|
<HeroBackdrop />
|
||||||
<HeroSection locale={locale} />
|
<HeroSection portfolio={portfolio} />
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
<RiverJourney locale={locale} />
|
{portfolio.sections.about.visible && <RiverJourney portfolio={portfolio} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,20 +3,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Code, BarChart2, Wand2 } from 'lucide-react';
|
import { Code, BarChart2, Wand2 } from 'lucide-react';
|
||||||
import {
|
import type { PortfolioProps } from '@/server/content';
|
||||||
skillsByDiscipline,
|
|
||||||
filterTabs,
|
|
||||||
FilterTab,
|
|
||||||
} from '@/data/skills';
|
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
||||||
import styles from './SkillsSection.module.css';
|
import styles from './SkillsSection.module.css';
|
||||||
|
|
||||||
export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
export const SkillsSection = ({ portfolio }: PortfolioProps) => {
|
||||||
const copy = messagesByLocale[locale].skills;
|
const copy = portfolio.messages.skills;
|
||||||
const [activeFilter, setActiveFilter] = useState<FilterTab['id']>('software-engineering');
|
const [activeFilter, setActiveFilter] = useState(portfolio.skills[0]?.id);
|
||||||
const activeDiscipline = skillsByDiscipline[activeFilter];
|
const activeDiscipline = portfolio.skills.find(item => item.id === activeFilter) ?? portfolio.skills[0];
|
||||||
|
|
||||||
const getFilterIcon = (id: FilterTab['id']) => {
|
const getFilterIcon = (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'software-engineering':
|
case 'software-engineering':
|
||||||
return <Code size={18} />;
|
return <Code size={18} />;
|
||||||
@@ -24,6 +19,8 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
return <BarChart2 size={18} />;
|
return <BarChart2 size={18} />;
|
||||||
case 'design-ux':
|
case 'design-ux':
|
||||||
return <Wand2 size={18} />;
|
return <Wand2 size={18} />;
|
||||||
|
default:
|
||||||
|
return <Code size={18} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,7 +110,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
aria-label={copy.filterLabel}
|
aria-label={copy.filterLabel}
|
||||||
data-reveal="soft"
|
data-reveal="soft"
|
||||||
>
|
>
|
||||||
{filterTabs.map((tab) => {
|
{portfolio.skills.map((tab) => {
|
||||||
const isActive = activeFilter === tab.id;
|
const isActive = activeFilter === tab.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -126,7 +123,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
} ${styles.filterButton}`}
|
} ${styles.filterButton}`}
|
||||||
>
|
>
|
||||||
{getFilterIcon(tab.id)}
|
{getFilterIcon(tab.id)}
|
||||||
<span>{copy.filterLabels[tab.id]}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</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