diff --git a/src/components/IsometricFooter/IsoTypes.ts b/src/components/IsometricFooter/IsoTypes.ts
index 6d8f521..bd4c0d0 100644
--- a/src/components/IsometricFooter/IsoTypes.ts
+++ b/src/components/IsometricFooter/IsoTypes.ts
@@ -70,4 +70,5 @@ export interface IsoSignpostProps {
export interface IsometricSceneProps {
ariaLabel: string;
+ contacts: Array<{ id: FooterContactId; label: string; href: string }>;
}
diff --git a/src/components/IsometricFooter/IsometricFooter.tsx b/src/components/IsometricFooter/IsometricFooter.tsx
index 970aeeb..fcfde44 100644
--- a/src/components/IsometricFooter/IsometricFooter.tsx
+++ b/src/components/IsometricFooter/IsometricFooter.tsx
@@ -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 (
@@ -61,9 +32,9 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
{/* Email Pill Button */}
-
+ {portfolio.profile.email &&
+
}
{/* 3D Isometric SVG Scene */}
-
+
{/* Responsive Synchronized Contact Cards Section */}
diff --git a/src/components/IsometricFooter/IsometricScene.tsx b/src/components/IsometricFooter/IsometricScene.tsx
index dc34d71..8b025b4 100644
--- a/src/components/IsometricFooter/IsometricScene.tsx
+++ b/src/components/IsometricFooter/IsometricScene.tsx
@@ -259,6 +259,7 @@ function CircuitConduits() {
*/
export default function IsometricScene({
ariaLabel,
+ contacts,
}: IsometricSceneProps) {
return (
);
}
diff --git a/src/components/MilestonesSection.tsx b/src/components/MilestonesSection.tsx
index 220649a..2d52a95 100644
--- a/src/components/MilestonesSection.tsx
+++ b/src/components/MilestonesSection.tsx
@@ -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 (
@@ -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 && (
diff --git a/src/components/ProjectsSection.tsx b/src/components/ProjectsSection.tsx
index afdbc89..17f5469 100644
--- a/src/components/ProjectsSection.tsx
+++ b/src/components/ProjectsSection.tsx
@@ -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(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)}
>
{
target="_blank"
rel="noopener noreferrer"
className={styles.titleLiveLink}
- aria-label={copy.liveProjectLabel(project.title)}
+ aria-label={formatMessage(copy.liveProjectLabel, { title: project.title }, locale)}
>
{
{
diff --git a/src/components/RiverJourney.tsx b/src/components/RiverJourney.tsx
index 2b124c6..511ece0 100644
--- a/src/components/RiverJourney.tsx
+++ b/src/components/RiverJourney.tsx
@@ -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 (
@@ -22,7 +22,7 @@ export const RiverJourney = ({ locale }: { locale: Locale }) => {
{copy.eyebrow}
{copy.title}
@@ -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 (
diff --git a/src/components/ScenicHeroJourney.tsx b/src/components/ScenicHeroJourney.tsx
index 7749ee8..ce5d83b 100644
--- a/src/components/ScenicHeroJourney.tsx
+++ b/src/components/ScenicHeroJourney.tsx
@@ -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 (
-
+ {portfolio.sections.hero.visible &&
+
+ }
-
+ {portfolio.sections.about.visible && }
);
};
diff --git a/src/components/SkillsSection.tsx b/src/components/SkillsSection.tsx
index 86ed064..124e08e 100644
--- a/src/components/SkillsSection.tsx
+++ b/src/components/SkillsSection.tsx
@@ -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('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 ;
@@ -24,6 +19,8 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
return ;
case 'design-ux':
return ;
+ default:
+ return ;
}
};
@@ -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 (
);
})}
diff --git a/src/server/public.ts b/src/server/public.ts
new file mode 100644
index 0000000..be84513
--- /dev/null
+++ b/src/server/public.ts
@@ -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; }
+}