feat: add route-backed project case studies
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@
|
|||||||
/dist/
|
/dist/
|
||||||
/build/
|
/build/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# Local environment files
|
# Local environment files
|
||||||
.env
|
.env
|
||||||
|
|||||||
78
e2e/project-case-study.spec.ts
Normal file
78
e2e/project-case-study.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const caseStudyPath = '/projects/qlockify';
|
||||||
|
|
||||||
|
async function expectDrawerOpen(page: import('@playwright/test').Page) {
|
||||||
|
const dialog = page.locator('dialog[open]');
|
||||||
|
await expect(dialog).toHaveAttribute('data-state', 'open');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a card CTA opens a route-backed drawer and restores the portfolio', async ({ page }) => {
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.scrollTo({ top: document.body.scrollHeight * 0.35, behavior: 'instant' });
|
||||||
|
});
|
||||||
|
const card = page.getByRole('article', { name: 'Qlockify' });
|
||||||
|
const cta = card.getByRole('link', { name: 'View case study' });
|
||||||
|
await cta.focus();
|
||||||
|
const portfolioScroll = await page.evaluate(() => window.scrollY);
|
||||||
|
await cta.press('Enter');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${caseStudyPath}$`));
|
||||||
|
const dialog = await expectDrawerOpen(page);
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'Qlockify', level: 1 })).toBeFocused();
|
||||||
|
await expect(card.getByRole('link', { name: /Open the live Qlockify/ })).toHaveAttribute('target', '_blank');
|
||||||
|
await expect(card.getByRole('link', { name: /Open the live Qlockify/ })).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||||
|
await expect(cta).toBeFocused();
|
||||||
|
await expect.poll(async () => Math.abs(await page.evaluate(() => window.scrollY) - portfolioScroll))
|
||||||
|
.toBeLessThan((page.viewportSize()?.height ?? 800) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shared route opens over the portfolio and adjacent navigation replaces history', async ({ page }) => {
|
||||||
|
await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' });
|
||||||
|
const dialog = await expectDrawerOpen(page);
|
||||||
|
await expect(page.getByRole('region', { name: 'Products I\'ve Brought to Life' })).toBeAttached();
|
||||||
|
|
||||||
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
|
await dialog.getByRole('link', { name: /Next project Guilan ACE/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/projects\/guilan-ace$/);
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'Guilan ACE', level: 1 })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(historyLength);
|
||||||
|
await dialog.getByRole('button', { name: 'Close case study' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy feedback, focus containment, and the localized RTL route work', async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
await page.goto('/fa/projects/qlockify', { waitUntil: 'domcontentloaded' });
|
||||||
|
const dialog = await expectDrawerOpen(page);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'Qlockify', level: 1 })).toBeFocused();
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: 'کپی پیوند' }).click();
|
||||||
|
await expect(dialog.getByText('پیوند کپی شد', { exact: true }).first()).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toMatch(/\/fa\/projects\/qlockify$/);
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
expect(await page.evaluate(() => document.activeElement?.closest('dialog') !== null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the drawer stays bottom-anchored with its own scrolling surface', async ({ page }) => {
|
||||||
|
await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' });
|
||||||
|
const dialog = await expectDrawerOpen(page);
|
||||||
|
const sheet = dialog.locator(':scope > div');
|
||||||
|
const scrollArea = sheet.locator(':scope > div:last-child');
|
||||||
|
const box = await sheet.boundingBox();
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(viewport).not.toBeNull();
|
||||||
|
expect(Math.abs((box?.y ?? 0) + (box?.height ?? 0) - (viewport?.height ?? 0))).toBeLessThanOrEqual(2);
|
||||||
|
expect(await page.locator('html').evaluate((element) => getComputedStyle(element).overflow)).toBe('hidden');
|
||||||
|
expect(await scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||||
|
});
|
||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"three": "^0.180.0"
|
"three": "^0.180.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -1407,6 +1408,22 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -5098,6 +5115,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test tests/*.test.mjs"
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"pretest:e2e": "node scripts/prepare-standalone.mjs",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"three": "^0.180.0"
|
"three": "^0.180.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
|
|||||||
32
playwright.config.ts
Normal file
32
playwright.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = Number(process.env.PLAYWRIGHT_PORT ?? 43119);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 90_000,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: 'line',
|
||||||
|
expect: { timeout: 30_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
actionTimeout: 15_000,
|
||||||
|
navigationTimeout: 60_000,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },
|
||||||
|
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], channel: 'chrome' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'node server.js',
|
||||||
|
cwd: '.next/standalone',
|
||||||
|
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
12
scripts/prepare-standalone.mjs
Normal file
12
scripts/prepare-standalone.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cp } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const projectRoot = path.resolve('.');
|
||||||
|
const standaloneRoot = path.join(projectRoot, '.next', 'standalone');
|
||||||
|
|
||||||
|
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
|
||||||
|
await cp(
|
||||||
|
path.join(projectRoot, '.next', 'static'),
|
||||||
|
path.join(standaloneRoot, '.next', 'static'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { SiteRoot } from '@/app/SiteRoot';
|
import { SiteRoot } from '@/app/SiteRoot';
|
||||||
|
import { PortfolioPage } from '@/app/PortfolioPage';
|
||||||
|
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
|
||||||
import { messagesByLocale } from '@/i18n/content';
|
import { messagesByLocale } from '@/i18n/content';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
@@ -8,5 +10,12 @@ export const metadata: Metadata = messagesByLocale.en.metadata;
|
|||||||
export default function EnglishLayout({
|
export default function EnglishLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return <SiteRoot locale="en">{children}</SiteRoot>;
|
return (
|
||||||
|
<SiteRoot locale="en">
|
||||||
|
<PortfolioRouteShell homePath="/">
|
||||||
|
<PortfolioPage locale="en" />
|
||||||
|
{children}
|
||||||
|
</PortfolioRouteShell>
|
||||||
|
</SiteRoot>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { PortfolioPage } from '@/app/PortfolioPage';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <PortfolioPage locale="en" />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/app/(en)/projects/[projectId]/page.tsx
Normal file
24
src/app/(en)/projects/[projectId]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
5
src/app/(en)/projects/layout.tsx
Normal file
5
src/app/(en)/projects/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
|
||||||
|
|
||||||
|
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <CaseStudyDrawer locale="en">{children}</CaseStudyDrawer>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { SiteRoot } from '@/app/SiteRoot';
|
import { SiteRoot } from '@/app/SiteRoot';
|
||||||
|
import { PortfolioPage } from '@/app/PortfolioPage';
|
||||||
|
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
|
||||||
import { messagesByLocale } from '@/i18n/content';
|
import { messagesByLocale } from '@/i18n/content';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
@@ -8,5 +10,12 @@ export const metadata: Metadata = messagesByLocale.fa.metadata;
|
|||||||
export default function PersianLayout({
|
export default function PersianLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return <SiteRoot locale="fa">{children}</SiteRoot>;
|
return (
|
||||||
|
<SiteRoot locale="fa">
|
||||||
|
<PortfolioRouteShell homePath="/fa">
|
||||||
|
<PortfolioPage locale="fa" />
|
||||||
|
{children}
|
||||||
|
</PortfolioRouteShell>
|
||||||
|
</SiteRoot>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { PortfolioPage } from '@/app/PortfolioPage';
|
|
||||||
|
|
||||||
export default function PersianHome() {
|
export default function PersianHome() {
|
||||||
return <PortfolioPage locale="fa" />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/app/(fa)/fa/projects/[projectId]/page.tsx
Normal file
24
src/app/(fa)/fa/projects/[projectId]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
5
src/app/(fa)/fa/projects/layout.tsx
Normal file
5
src/app/(fa)/fa/projects/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
|
||||||
|
|
||||||
|
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <CaseStudyDrawer locale="fa">{children}</CaseStudyDrawer>;
|
||||||
|
}
|
||||||
128
src/components/CaseStudyContent.tsx
Normal file
128
src/components/CaseStudyContent.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
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 styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
|
export function CaseStudyContent({
|
||||||
|
project,
|
||||||
|
locale,
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
locale: Locale;
|
||||||
|
previous: Project;
|
||||||
|
next: Project;
|
||||||
|
}) {
|
||||||
|
const copy = messagesByLocale[locale].projects;
|
||||||
|
const caseStudy = project.caseStudy;
|
||||||
|
const isRtl = locale === 'fa';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={styles.content}>
|
||||||
|
<header className={styles.hero}>
|
||||||
|
<p className={styles.date}>{project.date}</p>
|
||||||
|
<h1 id="case-study-title" className={styles.title} tabIndex={-1} data-case-study-heading>
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
<p id="case-study-introduction" className={styles.introduction}>{caseStudy.introduction}</p>
|
||||||
|
<dl className={styles.facts}>
|
||||||
|
<div><dt>{copy.roleLabel}</dt><dd>{caseStudy.role}</dd></div>
|
||||||
|
<div><dt>{copy.usersLabel}</dt><dd>{caseStudy.targetUsers}</dd></div>
|
||||||
|
<div><dt>{copy.statusLabel}</dt><dd>{caseStudy.status}</dd></div>
|
||||||
|
{caseStudy.team && <div><dt>{copy.teamLabel}</dt><dd>{caseStudy.team}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-gallery">
|
||||||
|
<h2 id="case-study-gallery" className={styles.sectionTitle}>{copy.galleryLabel}</h2>
|
||||||
|
<div className={styles.gallery}>
|
||||||
|
{caseStudy.gallery.map((item, index) => (
|
||||||
|
<figure key={`${item.image.src}-${index}`} className={index === 0 ? styles.featuredMedia : styles.media}>
|
||||||
|
<div className={styles.imageFrame}>
|
||||||
|
<Image
|
||||||
|
src={item.image.src}
|
||||||
|
width={item.image.width}
|
||||||
|
height={item.image.height}
|
||||||
|
alt={item.alt}
|
||||||
|
sizes={index === 0 ? '(max-width: 650px) 100vw, 68rem' : '(max-width: 650px) 100vw, 34rem'}
|
||||||
|
className={styles.galleryImage}
|
||||||
|
priority={index === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<figcaption>{item.caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.storyGrid}>
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-challenge">
|
||||||
|
<h2 id="case-study-challenge" className={styles.sectionTitle}>{copy.challengeLabel}</h2>
|
||||||
|
<p>{caseStudy.challenge}</p>
|
||||||
|
</section>
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-ownership">
|
||||||
|
<h2 id="case-study-ownership" className={styles.sectionTitle}>{copy.ownershipLabel}</h2>
|
||||||
|
<ul className={styles.list}>{caseStudy.ownership.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caseStudy.constraints && (
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-constraints">
|
||||||
|
<h2 id="case-study-constraints" className={styles.sectionTitle}>{copy.constraintsLabel}</h2>
|
||||||
|
<ul className={styles.list}>{caseStudy.constraints.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-decisions">
|
||||||
|
<h2 id="case-study-decisions" className={styles.sectionTitle}>{copy.decisionsLabel}</h2>
|
||||||
|
<div className={styles.decisions}>
|
||||||
|
{caseStudy.decisions.map((decision) => (
|
||||||
|
<article key={`${decision.discipline}-${decision.title}`} className={styles.decision} data-discipline={decision.discipline}>
|
||||||
|
<span>{copy.decisionLabels[decision.discipline]}</span>
|
||||||
|
<h3>{decision.title}</h3>
|
||||||
|
<p>{decision.description}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-outcomes">
|
||||||
|
<h2 id="case-study-outcomes" className={styles.sectionTitle}>{copy.outcomesLabel}</h2>
|
||||||
|
{caseStudy.metrics && <dl className={styles.metrics}>{caseStudy.metrics.map((metric) => <div key={metric.label}><dt>{metric.value}</dt><dd>{metric.label}</dd></div>)}</dl>}
|
||||||
|
<ul className={styles.list}>{caseStudy.outcomes.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.stackGrid}>
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-skills">
|
||||||
|
<h2 id="case-study-skills" className={styles.sectionTitle}>{copy.skillsLabel}</h2>
|
||||||
|
<div className={styles.badges}>{caseStudy.skills.map((skill) => <span key={skill}>{skill}</span>)}</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-tools">
|
||||||
|
<h2 id="case-study-tools" className={styles.sectionTitle}>{copy.toolsLabel}</h2>
|
||||||
|
<div className={`${styles.badges} ltr-token`} lang="en" dir="ltr">{caseStudy.tools.map((tool) => <span key={tool}>{tool}</span>)}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caseStudy.lessons && (
|
||||||
|
<section className={styles.section} aria-labelledby="case-study-lessons">
|
||||||
|
<h2 id="case-study-lessons" className={styles.sectionTitle}>{copy.lessonsLabel}</h2>
|
||||||
|
<ul className={styles.list}>{caseStudy.lessons.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav className={styles.projectNavigation} aria-label={copy.drawerLabel}>
|
||||||
|
<Link href={getProjectPath(locale, previous.id)} 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}`}>
|
||||||
|
<span><small>{copy.nextCaseStudy}</small><strong>{next.title}</strong></span>
|
||||||
|
{isRtl ? <ArrowLeft aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
552
src/components/CaseStudyDrawer.module.css
Normal file
552
src/components/CaseStudyDrawer.module.css
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
.dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: none;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog::backdrop {
|
||||||
|
background: rgba(1, 13, 39, 0.52);
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
transition: opacity 180ms var(--motion-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog[data-state='open']::backdrop {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog[data-state='closing']::backdrop {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(72rem, calc(100vw - 2rem));
|
||||||
|
height: min(86dvh, 56rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 -1rem 4rem rgba(1, 13, 39, 0.2);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 240ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog[data-state='open'] .sheet {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog[data-motion='none'] .sheet {
|
||||||
|
transition-duration: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utilityHeader {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
padding: 1rem var(--space-5) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 94%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragTarget {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 8rem;
|
||||||
|
height: 2rem;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragTarget:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerLabel {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-label-small);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: var(--letter-spacing-eyebrow);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 3;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton,
|
||||||
|
.iconButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
font-weight: 650;
|
||||||
|
transition:
|
||||||
|
color var(--motion-duration-fast) ease,
|
||||||
|
background-color var(--motion-duration-fast) ease,
|
||||||
|
border-color var(--motion-duration-fast) ease,
|
||||||
|
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
width: 2.75rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton:active,
|
||||||
|
.iconButton:active,
|
||||||
|
.projectNavLink:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveRegion {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollArea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-padding-top: var(--space-5);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: min(68rem, 100%);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--space-8) var(--space-7) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-label-small);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: var(--letter-spacing-eyebrow);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||||
|
font-weight: 780;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[dir='rtl']) .title,
|
||||||
|
:global(html[dir='rtl']) .date,
|
||||||
|
:global(html[dir='rtl']) .drawerLabel {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:focus-visible {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: 3px solid var(--focus-ring);
|
||||||
|
outline-offset: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction {
|
||||||
|
max-width: 46rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-body-large);
|
||||||
|
line-height: var(--line-height-body-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts > div {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-label-small);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dd {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: var(--line-height-body-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: var(--line-height-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section p,
|
||||||
|
.list {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-body-default);
|
||||||
|
line-height: var(--line-height-body-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featuredMedia {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageFrame {
|
||||||
|
display: flex;
|
||||||
|
min-height: 10rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-blue-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.galleryImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery figcaption {
|
||||||
|
padding: var(--space-2) var(--space-1) 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
line-height: var(--line-height-body-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyGrid,
|
||||||
|
.stackGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-inline-start: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li::marker {
|
||||||
|
color: var(--accent-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decisions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision > span {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--action-link);
|
||||||
|
font-size: var(--font-size-label-small);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision h3 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-body-default);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision p {
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
line-height: var(--line-height-body-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics > div {
|
||||||
|
min-width: 9rem;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics dt {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics dd {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges span {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavigation {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-9);
|
||||||
|
padding-top: var(--space-6);
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink {
|
||||||
|
display: flex;
|
||||||
|
min-height: 5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-duration-fast) ease,
|
||||||
|
border-color var(--motion-duration-fast) ease,
|
||||||
|
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--size-icon-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink span {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-label-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-body-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextProject {
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.sheet {
|
||||||
|
width: calc(100vw - 1rem);
|
||||||
|
height: 90dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-inline: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decisions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 650px) {
|
||||||
|
.sheet {
|
||||||
|
width: 100vw;
|
||||||
|
height: 92dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utilityHeader {
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerLabel {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionText {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
width: 2.75rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: var(--space-7) var(--space-4) calc(var(--space-7) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction {
|
||||||
|
font-size: var(--font-size-body-default);
|
||||||
|
line-height: var(--line-height-body-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts,
|
||||||
|
.gallery,
|
||||||
|
.storyGrid,
|
||||||
|
.stackGrid,
|
||||||
|
.projectNavigation {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featuredMedia {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.actionButton:hover,
|
||||||
|
.iconButton:hover,
|
||||||
|
.projectNavLink:hover {
|
||||||
|
border-color: var(--accent-default);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectNavLink:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sheet {
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog[data-state='opening'] .sheet,
|
||||||
|
.dialog[data-state='closing'] .sheet {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog::backdrop {
|
||||||
|
transition-duration: 120ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/components/CaseStudyDrawer.tsx
Normal file
158
src/components/CaseStudyDrawer.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
|
||||||
|
import { Check, Copy, X } from 'lucide-react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||||
|
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
||||||
|
import { useDrawerDrag } from './useDrawerDrag';
|
||||||
|
import styles from './CaseStudyDrawer.module.css';
|
||||||
|
|
||||||
|
type CopyState = 'idle' | 'copied' | 'error';
|
||||||
|
|
||||||
|
export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) {
|
||||||
|
const copy = messagesByLocale[locale].projects;
|
||||||
|
const pathname = usePathname();
|
||||||
|
const dismissRoute = useDismissCaseStudy();
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const backdropPressed = useRef(false);
|
||||||
|
const closing = useRef(false);
|
||||||
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const backgroundScrollY = useRef(0);
|
||||||
|
const [copyState, setCopyState] = useState<CopyState>('idle');
|
||||||
|
|
||||||
|
const finishDismiss = useCallback(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (dialog?.open) dialog.close();
|
||||||
|
returnFocusRef.current?.focus({ preventScroll: true });
|
||||||
|
dismissRoute();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: backgroundScrollY.current, behavior: 'instant' });
|
||||||
|
returnFocusRef.current?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [dismissRoute]);
|
||||||
|
|
||||||
|
const requestDismiss = useCallback((animate = true) => {
|
||||||
|
if (closing.current) return;
|
||||||
|
closing.current = true;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
const sheet = sheetRef.current;
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
if (!animate || reduceMotion || !dialog || !sheet) {
|
||||||
|
finishDismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dataset.state = 'closing';
|
||||||
|
sheet.style.transition = 'transform 180ms var(--motion-ease-out)';
|
||||||
|
sheet.style.transform = 'translateY(100%)';
|
||||||
|
window.setTimeout(finishDismiss, 180);
|
||||||
|
}, [finishDismiss]);
|
||||||
|
|
||||||
|
const dragHandlers = useDrawerDrag({ sheetRef, onDismiss: requestDismiss });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousOverflow = root.style.overflow;
|
||||||
|
const skipMovement = root.dataset.caseStudyInput === 'keyboard';
|
||||||
|
const recordedScrollY = Number(root.dataset.caseStudyScrollY);
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body
|
||||||
|
? activeElement
|
||||||
|
: null;
|
||||||
|
backgroundScrollY.current = Number.isFinite(recordedScrollY) ? recordedScrollY : window.scrollY;
|
||||||
|
delete root.dataset.caseStudyInput;
|
||||||
|
dialog.dataset.motion = skipMovement ? 'none' : 'standard';
|
||||||
|
dialog.showModal();
|
||||||
|
root.style.overflow = 'hidden';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
dialog.dataset.state = 'open';
|
||||||
|
dialog.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
root.style.overflow = previousOverflow;
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
if (returnFocusRef.current) {
|
||||||
|
const restoreScroll = () => window.scrollTo({
|
||||||
|
top: backgroundScrollY.current,
|
||||||
|
behavior: 'instant',
|
||||||
|
});
|
||||||
|
restoreScroll();
|
||||||
|
window.setTimeout(restoreScroll, 120);
|
||||||
|
window.setTimeout(restoreScroll, 600);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCopyState('idle');
|
||||||
|
dialogRef.current?.querySelector<HTMLElement>('[data-case-study-heading]')?.focus();
|
||||||
|
});
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const copyCurrentLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
setCopyState('copied');
|
||||||
|
} catch {
|
||||||
|
setCopyState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropDown = (event: PointerEvent<HTMLDialogElement>) => {
|
||||||
|
backdropPressed.current = event.target === event.currentTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropUp = (event: PointerEvent<HTMLDialogElement>) => {
|
||||||
|
if (backdropPressed.current && event.target === event.currentTarget) requestDismiss();
|
||||||
|
backdropPressed.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedback = copyState === 'copied' ? copy.linkCopied : copyState === 'error' ? copy.copyFailed : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={styles.dialog}
|
||||||
|
aria-labelledby="case-study-title"
|
||||||
|
aria-describedby="case-study-introduction"
|
||||||
|
data-state="opening"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
requestDismiss(false);
|
||||||
|
}}
|
||||||
|
onPointerDown={handleBackdropDown}
|
||||||
|
onPointerUp={handleBackdropUp}
|
||||||
|
>
|
||||||
|
<div ref={sheetRef} className={styles.sheet}>
|
||||||
|
<div className={styles.utilityHeader}>
|
||||||
|
<div className={styles.dragTarget} aria-hidden="true" {...dragHandlers}>
|
||||||
|
<span className={styles.dragHandle} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.drawerLabel}>{copy.drawerLabel}</span>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.actionButton} onClick={copyCurrentLink} aria-label={copy.copyLink}>
|
||||||
|
{copyState === 'copied' ? <Check size={19} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||||
|
<span className={styles.actionText}>{copyState === 'copied' ? copy.linkCopied : copy.copyLink}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.iconButton} onClick={(event) => requestDismiss(event.detail !== 0)} aria-label={copy.closeCaseStudy}>
|
||||||
|
<X size={21} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className={styles.liveRegion} aria-live="polite">{feedback}</span>
|
||||||
|
</div>
|
||||||
|
<div ref={scrollRef} className={styles.scrollArea}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/PortfolioRouteShell.tsx
Normal file
72
src/components/PortfolioRouteShell.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const PortfolioRouteContext = createContext<(() => void) | null>(null);
|
||||||
|
|
||||||
|
export function PortfolioRouteShell({
|
||||||
|
children,
|
||||||
|
homePath,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
homePath: string;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const previousPath = useRef(pathname);
|
||||||
|
const canReturnWithBack = useRef(false);
|
||||||
|
const returnScrollY = useRef<number | null>(null);
|
||||||
|
const previousScrollRestoration = useRef<ScrollRestoration>('auto');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === homePath) {
|
||||||
|
canReturnWithBack.current = false;
|
||||||
|
if (previousPath.current !== homePath && returnScrollY.current !== null) {
|
||||||
|
const scrollY = returnScrollY.current;
|
||||||
|
requestAnimationFrame(() => window.scrollTo({ top: scrollY, behavior: 'instant' }));
|
||||||
|
window.setTimeout(() => window.scrollTo({ top: scrollY, behavior: 'instant' }), 120);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.scrollTo({ top: scrollY, behavior: 'instant' });
|
||||||
|
history.scrollRestoration = previousScrollRestoration.current;
|
||||||
|
}, 600);
|
||||||
|
returnScrollY.current = null;
|
||||||
|
delete document.documentElement.dataset.caseStudyScrollY;
|
||||||
|
}
|
||||||
|
} else if (previousPath.current === homePath) {
|
||||||
|
canReturnWithBack.current = true;
|
||||||
|
const recordedScrollY = Number(document.documentElement.dataset.caseStudyScrollY);
|
||||||
|
returnScrollY.current = Number.isFinite(recordedScrollY) ? recordedScrollY : window.scrollY;
|
||||||
|
previousScrollRestoration.current = history.scrollRestoration;
|
||||||
|
history.scrollRestoration = 'manual';
|
||||||
|
}
|
||||||
|
previousPath.current = pathname;
|
||||||
|
}, [homePath, pathname]);
|
||||||
|
|
||||||
|
const dismissCaseStudy = useCallback(() => {
|
||||||
|
if (canReturnWithBack.current) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(homePath, { scroll: false });
|
||||||
|
}, [homePath, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortfolioRouteContext.Provider value={dismissCaseStudy}>
|
||||||
|
{children}
|
||||||
|
</PortfolioRouteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDismissCaseStudy() {
|
||||||
|
const dismiss = useContext(PortfolioRouteContext);
|
||||||
|
if (!dismiss) throw new Error('useDismissCaseStudy must be used within PortfolioRouteShell');
|
||||||
|
return dismiss;
|
||||||
|
}
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
.cardTitleRow {
|
.cardTitleRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4375rem;
|
gap: var(--space-1);
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +168,17 @@
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleLiveLink {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
margin-block: -0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.titleLinkIcon {
|
.titleLinkIcon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
@@ -506,7 +517,7 @@
|
|||||||
transform: scale(1.035);
|
transform: scale(1.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover .titleLinkIcon {
|
.titleLiveLink:hover .titleLinkIcon {
|
||||||
transform: translate(2px, -2px);
|
transform: translate(2px, -2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||||
import { getProjects } from '@/data/projects';
|
import { getProjectPath, getProjects } from '@/data/projects';
|
||||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||||
import { ProjectThumbnail } from './ProjectThumbnail';
|
import { ProjectThumbnail } from './ProjectThumbnail';
|
||||||
import styles from './ProjectsSection.module.css';
|
import styles from './ProjectsSection.module.css';
|
||||||
@@ -369,13 +370,21 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
|
|
||||||
<div className={styles.cardBody} data-reveal="soft">
|
<div className={styles.cardBody} data-reveal="soft">
|
||||||
<div className={styles.cardTitleRow}>
|
<div className={styles.cardTitleRow}>
|
||||||
{project.link && isRtl && (
|
{project.liveUrl && isRtl && (
|
||||||
<ArrowUpRight
|
<a
|
||||||
size={19}
|
href={project.liveUrl}
|
||||||
strokeWidth={2.5}
|
target="_blank"
|
||||||
className={styles.titleLinkIcon}
|
rel="noopener noreferrer"
|
||||||
aria-hidden="true"
|
className={styles.titleLiveLink}
|
||||||
/>
|
aria-label={copy.liveProjectLabel(project.title)}
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
size={19}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
className={styles.titleLinkIcon}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
<h3
|
<h3
|
||||||
className={`${styles.cardTitle} ${project.id === 'qlockify' ? 'ltr-token' : ''}`}
|
className={`${styles.cardTitle} ${project.id === 'qlockify' ? 'ltr-token' : ''}`}
|
||||||
@@ -384,13 +393,21 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
>
|
>
|
||||||
{project.title}
|
{project.title}
|
||||||
</h3>
|
</h3>
|
||||||
{project.link && !isRtl && (
|
{project.liveUrl && !isRtl && (
|
||||||
<ArrowUpRight
|
<a
|
||||||
size={19}
|
href={project.liveUrl}
|
||||||
strokeWidth={2.5}
|
target="_blank"
|
||||||
className={styles.titleLinkIcon}
|
rel="noopener noreferrer"
|
||||||
aria-hidden="true"
|
className={styles.titleLiveLink}
|
||||||
/>
|
aria-label={copy.liveProjectLabel(project.title)}
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
size={19}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
className={styles.titleLinkIcon}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -411,7 +428,16 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardFooter}>
|
<div className={styles.cardFooter}>
|
||||||
<a href={project.link || '#'} className={styles.caseStudyLink}>
|
<Link
|
||||||
|
href={getProjectPath(locale, project.id)}
|
||||||
|
scroll={false}
|
||||||
|
className={styles.caseStudyLink}
|
||||||
|
onClick={(event) => {
|
||||||
|
document.documentElement.dataset.caseStudyInput =
|
||||||
|
event.detail === 0 ? 'keyboard' : 'pointer';
|
||||||
|
document.documentElement.dataset.caseStudyScrollY = String(window.scrollY);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isRtl && (
|
{isRtl && (
|
||||||
<ArrowLeft size={18} strokeWidth={2.25} aria-hidden="true" />
|
<ArrowLeft size={18} strokeWidth={2.25} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
@@ -419,7 +445,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|||||||
{!isRtl && (
|
{!isRtl && (
|
||||||
<ArrowRight size={18} strokeWidth={2.25} aria-hidden="true" />
|
<ArrowRight size={18} strokeWidth={2.25} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
72
src/components/useDrawerDrag.ts
Normal file
72
src/components/useDrawerDrag.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
|
||||||
|
|
||||||
|
const DISMISS_VELOCITY = 0.11;
|
||||||
|
const DISMISS_DISTANCE_RATIO = 0.25;
|
||||||
|
|
||||||
|
type DragEvent = ReactPointerEvent<HTMLDivElement>;
|
||||||
|
|
||||||
|
export function useDrawerDrag({
|
||||||
|
sheetRef,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
sheetRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const drag = useRef({ active: false, pointerId: -1, startY: 0, startedAt: 0 });
|
||||||
|
|
||||||
|
const resetSheet = () => {
|
||||||
|
const sheet = sheetRef.current;
|
||||||
|
if (!sheet) return;
|
||||||
|
sheet.style.transition = 'transform 200ms var(--motion-ease-out)';
|
||||||
|
sheet.style.transform = 'translateY(0)';
|
||||||
|
sheet.addEventListener('transitionend', () => {
|
||||||
|
sheet.style.removeProperty('transition');
|
||||||
|
sheet.style.removeProperty('transform');
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event: DragEvent) => {
|
||||||
|
if (drag.current.active || (event.pointerType === 'mouse' && event.button !== 0)) return;
|
||||||
|
drag.current = {
|
||||||
|
active: true,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startY: event.clientY,
|
||||||
|
startedAt: performance.now(),
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
sheetRef.current?.style.setProperty('transition', 'none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: DragEvent) => {
|
||||||
|
if (!drag.current.active || drag.current.pointerId !== event.pointerId) return;
|
||||||
|
const rawDistance = event.clientY - drag.current.startY;
|
||||||
|
const distance = rawDistance < 0 ? rawDistance * 0.12 : rawDistance;
|
||||||
|
if (sheetRef.current) sheetRef.current.style.transform = `translateY(${distance}px)`;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishDrag = (event: DragEvent, cancelled = false) => {
|
||||||
|
if (!drag.current.active || drag.current.pointerId !== event.pointerId) return;
|
||||||
|
const distance = Math.max(0, event.clientY - drag.current.startY);
|
||||||
|
const elapsed = Math.max(1, performance.now() - drag.current.startedAt);
|
||||||
|
const velocity = distance / elapsed;
|
||||||
|
const sheetHeight = sheetRef.current?.getBoundingClientRect().height ?? 1;
|
||||||
|
drag.current.active = false;
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (distance >= sheetHeight * DISMISS_DISTANCE_RATIO || velocity > DISMISS_VELOCITY)) {
|
||||||
|
onDismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp: (event: DragEvent) => finishDrag(event),
|
||||||
|
onPointerCancel: (event: DragEvent) => finishDrag(event, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,100 +1,335 @@
|
|||||||
import type { Locale, Localized } from '@/i18n/content';
|
import type { Locale, Localized } from '@/i18n/content';
|
||||||
import { portfolioImage, type PortfolioImage } from '@/types/media';
|
import { portfolioImage, type PortfolioImage } from '@/types/media';
|
||||||
|
|
||||||
|
export type CaseStudyDiscipline = 'product' | 'engineering' | 'design';
|
||||||
|
|
||||||
|
export interface CaseStudyDecision {
|
||||||
|
discipline: CaseStudyDiscipline;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseStudyGalleryItem {
|
||||||
|
image: PortfolioImage;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCaseStudy {
|
||||||
|
introduction: string;
|
||||||
|
role: string;
|
||||||
|
targetUsers: string;
|
||||||
|
status: string;
|
||||||
|
challenge: string;
|
||||||
|
ownership: string[];
|
||||||
|
decisions: CaseStudyDecision[];
|
||||||
|
outcomes: string[];
|
||||||
|
skills: string[];
|
||||||
|
tools: string[];
|
||||||
|
team?: string;
|
||||||
|
constraints?: string[];
|
||||||
|
metrics?: Array<{ value: string; label: string }>;
|
||||||
|
lessons?: string[];
|
||||||
|
gallery: CaseStudyGalleryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
link?: string;
|
liveUrl?: string;
|
||||||
date: string;
|
date: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
image: PortfolioImage;
|
image: PortfolioImage;
|
||||||
|
caseStudy: ProjectCaseStudy;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectCopy = Pick<Project, 'title' | 'date' | 'description'>;
|
type GalleryCopy = Pick<CaseStudyGalleryItem, 'alt' | 'caption'>;
|
||||||
|
type CaseStudyCopy = Omit<ProjectCaseStudy, 'gallery'>;
|
||||||
|
type ProjectCopy = Pick<Project, 'title' | 'date' | 'description'> & {
|
||||||
|
caseStudy: CaseStudyCopy;
|
||||||
|
};
|
||||||
|
|
||||||
interface ProjectDefinition extends Omit<Project, keyof ProjectCopy> {
|
interface GalleryDefinition {
|
||||||
|
image: PortfolioImage;
|
||||||
|
content: Localized<GalleryCopy>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDefinition
|
||||||
|
extends Omit<Project, keyof ProjectCopy | 'caseStudy'> {
|
||||||
|
gallery: GalleryDefinition[];
|
||||||
content: Localized<ProjectCopy>;
|
content: Localized<ProjectCopy>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectDefinitions: ProjectDefinition[] = [
|
const projectDefinitions: ProjectDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'qlockify',
|
id: 'qlockify',
|
||||||
link: 'https://qlockfiy.ir',
|
liveUrl: 'https://qlockfiy.ir',
|
||||||
tags: ['Django', 'React', 'Docker', 'UI/UX Design'],
|
tags: ['Django', 'React', 'Docker', 'UI/UX Design'],
|
||||||
image: portfolioImage('/images/project-qlockify.png', 359, 200),
|
image: portfolioImage('/images/project-qlockify.png', 359, 200),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-qlockify.png', 359, 200),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Qlockify time tracking product in light mode', caption: 'The public Qlockify experience in light mode.' },
|
||||||
|
fa: { alt: 'محصول ثبت زمان Qlockify در حالت روشن', caption: 'نمای عمومی Qlockify در حالت روشن.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-qlockify-dark.png', 358, 199),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Qlockify time tracking product in dark mode', caption: 'The same product experience in dark mode.' },
|
||||||
|
fa: { alt: 'محصول ثبت زمان Qlockify در حالت تیره', caption: 'همان تجربه محصول در حالت تیره.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Qlockify',
|
title: 'Qlockify',
|
||||||
date: 'FEB 2026 - PRESENT',
|
date: 'FEB 2026 - PRESENT',
|
||||||
description:
|
description: 'Built a production-ready time tracking platform for teams and freelancers to manage workspaces, projects, billable hours, and reports in one place. Users can track their work time, organize it by project, manage access inside each workspace, generate reports for admins and clients, and export insights.',
|
||||||
'Built a production-ready time tracking platform for teams and freelancers to manage workspaces, projects, billable hours, and reports in one place. Users can track their work time, organize it by project, manage access inside each workspace, generate reports for admins and clients, and export insights.',
|
caseStudy: {
|
||||||
|
introduction: 'A single workspace for recording work, organizing billable time, managing access, and turning tracked activity into useful reports.',
|
||||||
|
role: 'Full-stack developer and UI/UX designer',
|
||||||
|
targetUsers: 'Teams, freelancers, workspace administrators, and clients',
|
||||||
|
status: 'In progress',
|
||||||
|
challenge: 'Time, project context, access control, and reporting are often split across separate workflows. Qlockify brings those jobs into one product while preserving the needs of team members, administrators, and clients.',
|
||||||
|
ownership: [
|
||||||
|
'Built time tracking and project organization workflows.',
|
||||||
|
'Implemented workspace access management.',
|
||||||
|
'Created reporting and export flows for administrators and clients.',
|
||||||
|
'Designed the interface across the product’s core workflows.',
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'Workspace-centered organization', description: 'Kept projects, people, tracked time, and reports inside a shared workspace model so related work stays connected.' },
|
||||||
|
{ discipline: 'engineering', title: 'Separated application responsibilities', description: 'Used Django for the backend, React for the interface, and Docker for repeatable deployment.' },
|
||||||
|
{ discipline: 'design', title: 'Role-aware reporting', description: 'Organized reporting around the different information needs of team members, administrators, and clients.' },
|
||||||
|
],
|
||||||
|
outcomes: [
|
||||||
|
'Consolidated time tracking, project organization, access management, reporting, and exports into one platform.',
|
||||||
|
'Made tracked work reusable for internal administration and client-facing reporting.',
|
||||||
|
],
|
||||||
|
skills: ['Product engineering', 'Backend development', 'Frontend development', 'UI/UX design'],
|
||||||
|
tools: ['Django', 'React', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'Qlockify',
|
title: 'Qlockify',
|
||||||
date: 'بهمن ۱۴۰۴ تا امروز',
|
date: 'بهمن ۱۴۰۴ تا امروز',
|
||||||
description:
|
description: 'یک پلتفرم آماده استفاده در محیط واقعی برای ثبت زمان تیمها و فریلنسرها ساختم تا فضاهای کاری، پروژهها، ساعتهای قابلصورتحساب و گزارشها را یکجا مدیریت کنند. کاربران میتوانند زمان کارشان را ثبت و بر اساس پروژه دستهبندی کنند، دسترسیهای هر فضای کاری را مدیریت کنند، برای مدیران و مشتریان گزارش بسازند و خروجی بگیرند.',
|
||||||
'یک پلتفرم آماده استفاده در محیط واقعی برای ثبت زمان تیمها و فریلنسرها ساختم تا فضاهای کاری، پروژهها، ساعتهای قابلصورتحساب و گزارشها را یکجا مدیریت کنند. کاربران میتوانند زمان کارشان را ثبت و بر اساس پروژه دستهبندی کنند، دسترسیهای هر فضای کاری را مدیریت کنند، برای مدیران و مشتریان گزارش بسازند و خروجی بگیرند.',
|
caseStudy: {
|
||||||
|
introduction: 'یک فضای کاری یکپارچه برای ثبت فعالیت، ساماندهی زمان قابلصورتحساب، مدیریت دسترسی و تبدیل دادههای زمانی به گزارشهای کاربردی.',
|
||||||
|
role: 'توسعهدهنده Full-stack و طراح UI/UX',
|
||||||
|
targetUsers: 'تیمها، فریلنسرها، مدیران فضای کاری و مشتریان',
|
||||||
|
status: 'در حال توسعه',
|
||||||
|
challenge: 'اطلاعات زمان، پروژه، دسترسی و گزارش معمولا در چند فرایند جدا قرار میگیرند. Qlockify این نیازها را در یک محصول جمع میکند و تفاوت نیاز اعضای تیم، مدیر و مشتری را حفظ میکند.',
|
||||||
|
ownership: [
|
||||||
|
'تجربه ثبت زمان و ساماندهی فعالیتها بر اساس پروژه را توسعه دادم.',
|
||||||
|
'مدیریت دسترسی افراد در هر فضای کاری را پیادهسازی کردم.',
|
||||||
|
'فرایند گزارشگیری و دریافت خروجی برای مدیران و مشتریان را ساختم.',
|
||||||
|
'رابط کاربری مسیرهای اصلی محصول را طراحی کردم.',
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'ساماندهی حول فضای کاری', description: 'پروژهها، افراد، زمانهای ثبتشده و گزارشها در یک مدل فضای کاری قرار گرفتند تا اطلاعات مرتبط از هم جدا نشوند.' },
|
||||||
|
{ discipline: 'engineering', title: 'تفکیک مسئولیتهای برنامه', description: 'برای بخش سمت سرور از Django، برای رابط از React و برای استقرار تکرارپذیر از Docker استفاده شد.' },
|
||||||
|
{ discipline: 'design', title: 'گزارش متناسب با نقش کاربران', description: 'ساختار گزارشها بر اساس نیاز متفاوت اعضای تیم، مدیران و مشتریان شکل گرفت.' },
|
||||||
|
],
|
||||||
|
outcomes: [
|
||||||
|
'ثبت زمان، ساماندهی پروژه، مدیریت دسترسی، گزارش و خروجی در یک پلتفرم یکپارچه شد.',
|
||||||
|
'دادههای ثبتشده هم برای مدیریت داخلی و هم برای گزارش به مشتری قابلاستفاده شدند.',
|
||||||
|
],
|
||||||
|
skills: ['مهندسی محصول', 'توسعه Backend', 'توسعه Frontend', 'طراحی UI/UX'],
|
||||||
|
tools: ['Django', 'React', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'guilan-ace',
|
id: 'guilan-ace',
|
||||||
link: 'https://east-guilan-ce.ir/',
|
liveUrl: 'https://east-guilan-ce.ir/',
|
||||||
tags: ['Django Ninja', 'Next.js', 'Docker'],
|
tags: ['Django Ninja', 'Next.js', 'Docker'],
|
||||||
image: portfolioImage('/images/project-guilan-ace.png', 359, 200),
|
image: portfolioImage('/images/project-guilan-ace.png', 359, 200),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-guilan-ace.png', 359, 200),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Guilan ACE website in light mode', caption: 'The association website in light mode.' },
|
||||||
|
fa: { alt: 'وبسایت انجمن علمی مهندسی کامپیوتر گیلان در حالت روشن', caption: 'نمای وبسایت انجمن در حالت روشن.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-guilan-ace-dark.png', 358, 199),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Guilan ACE website in dark mode', caption: 'The association website in dark mode.' },
|
||||||
|
fa: { alt: 'وبسایت انجمن علمی مهندسی کامپیوتر گیلان در حالت تیره', caption: 'نمای وبسایت انجمن در حالت تیره.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Guilan ACE',
|
title: 'Guilan ACE',
|
||||||
date: 'JUL 2025 - PRESENT',
|
date: 'JUL 2025 - PRESENT',
|
||||||
description:
|
description: 'Developed the Scientific Association of Computer Engineering website for event management and article publishing, helping members organize activities and users register for events, read blog posts, and share ideas through comments.',
|
||||||
'Developed the Scientific Association of Computer Engineering website for event management and article publishing, helping members organize activities and users register for events, read blog posts, and share ideas through comments.',
|
caseStudy: {
|
||||||
|
introduction: 'A shared digital home for the association’s events, articles, registrations, and community discussion.',
|
||||||
|
role: 'Full-stack developer',
|
||||||
|
targetUsers: 'Association members, students, event attendees, and readers',
|
||||||
|
status: 'In progress',
|
||||||
|
challenge: 'The association needed one place to publish knowledge and coordinate event activity for both organizers and participants.',
|
||||||
|
ownership: ['Developed event management and registration workflows.', 'Built article publishing and reading experiences.', 'Supported community participation through comments.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'One home for activity and knowledge', description: 'Combined events and publishing so visitors can discover upcoming activity and reusable educational content.' },
|
||||||
|
{ discipline: 'engineering', title: 'API and interface separation', description: 'Used Django Ninja for application APIs, Next.js for the web experience, and Docker for deployment.' },
|
||||||
|
{ discipline: 'design', title: 'Clear participant journeys', description: 'Kept registration, reading, and commenting understandable for visitors who are not site administrators.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Gave members a centralized way to organize events and publish articles.', 'Enabled visitors to register, read, and participate from the same website.'],
|
||||||
|
skills: ['Full-stack development', 'Information architecture', 'Community product design'],
|
||||||
|
tools: ['Django Ninja', 'Next.js', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'انجمن علمی مهندسی کامپیوتر گیلان',
|
title: 'انجمن علمی مهندسی کامپیوتر گیلان',
|
||||||
date: 'تیر ۱۴۰۴ تا امروز',
|
date: 'تیر ۱۴۰۴ تا امروز',
|
||||||
description:
|
description: 'وبسایت انجمن علمی مهندسی کامپیوتر را برای مدیریت رویدادها و انتشار مقاله توسعه دادم. اعضا میتوانند فعالیتها را سامان دهند و کاربران هم برای رویدادها ثبتنام کنند، نوشتههای وبلاگ را بخوانند و نظرشان را در بخش دیدگاهها به اشتراک بگذارند.',
|
||||||
'وبسایت انجمن علمی مهندسی کامپیوتر را برای مدیریت رویدادها و انتشار مقاله توسعه دادم. اعضا میتوانند فعالیتها را سامان دهند و کاربران هم برای رویدادها ثبتنام کنند، نوشتههای وبلاگ را بخوانند و نظرشان را در بخش دیدگاهها به اشتراک بگذارند.',
|
caseStudy: {
|
||||||
|
introduction: 'یک خانه دیجیتال مشترک برای رویدادها، مقالهها، ثبتنام و گفتوگوی اعضای انجمن.',
|
||||||
|
role: 'توسعهدهنده Full-stack',
|
||||||
|
targetUsers: 'اعضای انجمن، دانشجویان، شرکتکنندگان رویداد و خوانندگان',
|
||||||
|
status: 'در حال توسعه',
|
||||||
|
challenge: 'انجمن به فضایی یکپارچه نیاز داشت تا هم دانش را منتشر کند و هم فعالیتهای رویداد را برای برگزارکنندگان و شرکتکنندگان سامان دهد.',
|
||||||
|
ownership: ['فرایندهای مدیریت رویداد و ثبتنام را توسعه دادم.', 'تجربه انتشار و مطالعه مقالهها را ساختم.', 'امکان مشارکت کاربران از طریق دیدگاهها را فراهم کردم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'یک خانه برای فعالیت و دانش', description: 'رویدادها و انتشار مقاله کنار هم قرار گرفتند تا بازدیدکنندگان فعالیتهای پیش رو و محتوای آموزشی ماندگار را پیدا کنند.' },
|
||||||
|
{ discipline: 'engineering', title: 'تفکیک API و رابط', description: 'برای APIهای برنامه از Django Ninja، برای تجربه وب از Next.js و برای استقرار از Docker استفاده شد.' },
|
||||||
|
{ discipline: 'design', title: 'مسیر روشن برای شرکتکنندگان', description: 'ثبتنام، مطالعه و ارسال دیدگاه برای کاربرانی که مدیر سایت نیستند، ساده و قابلفهم نگه داشته شد.' },
|
||||||
|
],
|
||||||
|
outcomes: ['اعضا به فضایی متمرکز برای مدیریت رویداد و انتشار مقاله دسترسی پیدا کردند.', 'بازدیدکنندگان توانستند در همان وبسایت ثبتنام کنند، بخوانند و مشارکت داشته باشند.'],
|
||||||
|
skills: ['توسعه Full-stack', 'معماری اطلاعات', 'طراحی محصول اجتماعی'],
|
||||||
|
tools: ['Django Ninja', 'Next.js', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'zarinpal',
|
id: 'zarinpal',
|
||||||
link: 'https://zarinpal.amiirkhl.ir/',
|
liveUrl: 'https://zarinpal.amiirkhl.ir/',
|
||||||
tags: ['Go', 'SvelteKit', 'Docker'],
|
tags: ['Go', 'SvelteKit', 'Docker'],
|
||||||
image: portfolioImage('/images/project-zarinpal.png', 359, 200),
|
image: portfolioImage('/images/project-zarinpal.png', 359, 200),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-zarinpal.png', 359, 200),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Zarinpal analytics dashboard in light mode', caption: 'Transaction analytics and campaign guidance in light mode.' },
|
||||||
|
fa: { alt: 'داشبورد تحلیلی زرینپال در حالت روشن', caption: 'نمای تحلیل تراکنش و پیشنهاد کمپین در حالت روشن.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-zarinpal-dark.png', 358, 199),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Zarinpal analytics dashboard in dark mode', caption: 'The analytics experience in dark mode.' },
|
||||||
|
fa: { alt: 'داشبورد تحلیلی زرینپال در حالت تیره', caption: 'نمای تجربه تحلیلی در حالت تیره.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Zarinpal Analytic Dashboard',
|
title: 'Zarinpal Analytic Dashboard',
|
||||||
date: 'AUG 2026',
|
date: 'AUG 2026',
|
||||||
description:
|
description: "AI-based dashboard for analytics insight on merchant's transactions in their different terminals and suggesting profitable campaigns to hold in special occasions based on their historical transactions and upcoming events in Persian calendar and international events that affect Iranian markets.",
|
||||||
"AI-based dashboard for analytics insight on merchant's transactions in their different terminals and suggesting profitable campaigns to hold in special occasions based on their historical transactions and upcoming events in Persian calendar and international events that affect Iranian markets.",
|
caseStudy: {
|
||||||
|
introduction: 'An analytics dashboard that connects multi-terminal transaction history with timely campaign suggestions.',
|
||||||
|
role: 'Full-stack developer',
|
||||||
|
targetUsers: 'Merchants operating one or more payment terminals',
|
||||||
|
status: 'Completed',
|
||||||
|
challenge: 'Merchants need to understand activity across terminals and turn historical patterns into useful decisions around upcoming market occasions.',
|
||||||
|
ownership: ['Built analytics views for transaction activity across different terminals.', 'Connected historical transaction context to campaign suggestions.', 'Included Persian-calendar and international events that affect Iranian markets.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'From reporting to action', description: 'Extended transaction insight into campaign suggestions so the dashboard supports decisions, not only observation.' },
|
||||||
|
{ discipline: 'engineering', title: 'Purpose-built service and interface', description: 'Used Go for application services, SvelteKit for the dashboard experience, and Docker for deployment.' },
|
||||||
|
{ discipline: 'design', title: 'Terminal-level context', description: 'Kept terminal differences visible so aggregate analytics do not hide where activity originated.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Combined transaction analysis and occasion-aware campaign guidance in one dashboard.', 'Made historical activity across multiple terminals easier to review as decision context.'],
|
||||||
|
skills: ['Analytics product development', 'Backend development', 'Dashboard design'],
|
||||||
|
tools: ['Go', 'SvelteKit', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'داشبورد تحلیلی زرینپال',
|
title: 'داشبورد تحلیلی زرینپال',
|
||||||
date: 'مرداد ۱۴۰۵',
|
date: 'مرداد ۱۴۰۵',
|
||||||
description:
|
description: 'داشبوردی مبتنی بر هوش مصنوعی برای تحلیل تراکنشهای پذیرنده در پایانههای مختلف و پیشنهاد کمپینهای سودآور برای مناسبتها ساختم. پیشنهادها بر اساس سابقه تراکنشها، رویدادهای پیش رو در تقویم فارسی و رویدادهای بینالمللی اثرگذار بر بازار ایران تهیه میشوند.',
|
||||||
'داشبوردی مبتنی بر هوش مصنوعی برای تحلیل تراکنشهای پذیرنده در پایانههای مختلف و پیشنهاد کمپینهای سودآور برای مناسبتها ساختم. پیشنهادها بر اساس سابقه تراکنشها، رویدادهای پیش رو در تقویم فارسی و رویدادهای بینالمللی اثرگذار بر بازار ایران تهیه میشوند.',
|
caseStudy: {
|
||||||
|
introduction: 'داشبوردی تحلیلی که سابقه تراکنش در پایانههای مختلف را به پیشنهادهای بهموقع برای کمپینها متصل میکند.',
|
||||||
|
role: 'توسعهدهنده Full-stack',
|
||||||
|
targetUsers: 'پذیرندگانی که یک یا چند پایانه پرداخت را مدیریت میکنند',
|
||||||
|
status: 'تکمیلشده',
|
||||||
|
challenge: 'پذیرندگان باید فعالیت پایانههای مختلف را بشناسند و الگوهای تاریخی را به تصمیمهای کاربردی برای مناسبتهای پیش رو تبدیل کنند.',
|
||||||
|
ownership: ['نماهای تحلیلی تراکنشها در پایانههای مختلف را توسعه دادم.', 'سابقه تراکنشها را به پیشنهادهای کمپین مرتبط کردم.', 'رویدادهای تقویم فارسی و مناسبتهای بینالمللی اثرگذار بر بازار ایران را در نظر گرفتم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'حرکت از گزارش به اقدام', description: 'تحلیل تراکنش به پیشنهاد کمپین گسترش یافت تا داشبورد فقط ابزار مشاهده نباشد و به تصمیمگیری کمک کند.' },
|
||||||
|
{ discipline: 'engineering', title: 'سرویس و رابط متناسب با مسئله', description: 'برای سرویسهای برنامه از Go، برای تجربه داشبورد از SvelteKit و برای استقرار از Docker استفاده شد.' },
|
||||||
|
{ discipline: 'design', title: 'حفظ زمینه هر پایانه', description: 'تفاوت پایانهها در رابط باقی ماند تا تحلیل تجمیعی، منبع فعالیتها را پنهان نکند.' },
|
||||||
|
],
|
||||||
|
outcomes: ['تحلیل تراکنش و پیشنهاد کمپین متناسب با مناسبت در یک داشبورد جمع شد.', 'بررسی سابقه فعالیت چند پایانه بهعنوان زمینه تصمیمگیری سادهتر شد.'],
|
||||||
|
skills: ['توسعه محصول تحلیلی', 'توسعه Backend', 'طراحی داشبورد'],
|
||||||
|
tools: ['Go', 'SvelteKit', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'iran-implant',
|
id: 'iran-implant',
|
||||||
link: 'http://impinternal.com/',
|
liveUrl: 'http://impinternal.com/',
|
||||||
tags: ['Django', 'Nginx', 'Docker', 'Label'],
|
tags: ['Django', 'Nginx', 'Docker', 'Label'],
|
||||||
image: portfolioImage('/images/project-iran-implant.png', 359, 200),
|
image: portfolioImage('/images/project-iran-implant.png', 359, 200),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-iran-implant.png', 359, 200),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Iran Implant internal clinic management system', caption: 'The centralized internal management experience.' },
|
||||||
|
fa: { alt: 'سامانه مدیریت داخلی کلینیک ایران ایمپلنت', caption: 'نمای سامانه متمرکز مدیریت داخلی.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Iran Implant',
|
title: 'Iran Implant',
|
||||||
date: 'NOV 2025 - MAR 2026',
|
date: 'NOV 2025 - MAR 2026',
|
||||||
description:
|
description: 'Developed the backend of Iran Implant, an internal management system for a specialized dental clinic, enabling staff to manage appointments, treatment rooms, payments, prescriptions, and laboratory orders through a centralized platform.',
|
||||||
'Developed the backend of Iran Implant, an internal management system for a specialized dental clinic, enabling staff to manage appointments, treatment rooms, payments, prescriptions, and laboratory orders through a centralized platform.',
|
caseStudy: {
|
||||||
|
introduction: 'A centralized internal system for coordinating the operational workflows of a specialized dental clinic.',
|
||||||
|
role: 'Backend developer',
|
||||||
|
targetUsers: 'Dental clinic staff',
|
||||||
|
status: 'Completed',
|
||||||
|
challenge: 'Clinic staff needed one operational system for several connected workflows that otherwise require separate records and coordination.',
|
||||||
|
ownership: ['Developed backend capabilities for appointment and treatment-room management.', 'Supported payment and prescription workflows.', 'Implemented laboratory order management inside the centralized system.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'Centralized clinic operations', description: 'Kept appointments, rooms, payments, prescriptions, and laboratory orders connected inside one staff workflow.' },
|
||||||
|
{ discipline: 'engineering', title: 'Production-oriented backend delivery', description: 'Used Django for backend development with Nginx and Docker in the deployment stack.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Provided clinic staff with one platform for managing core internal workflows.', 'Connected clinical coordination and administrative records within the same system.'],
|
||||||
|
skills: ['Backend development', 'Workflow modeling', 'Deployment engineering'],
|
||||||
|
tools: ['Django', 'Nginx', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'ایران ایمپلنت',
|
title: 'ایران ایمپلنت',
|
||||||
date: 'آبان ۱۴۰۴ تا اسفند ۱۴۰۴',
|
date: 'آبان ۱۴۰۴ تا اسفند ۱۴۰۴',
|
||||||
description:
|
description: 'بخش سمت سرور سامانه مدیریت داخلی ایران ایمپلنت را برای یک کلینیک تخصصی دندانپزشکی توسعه دادم. کارکنان از طریق یک سامانه متمرکز، نوبتها، اتاقهای درمان، پرداختها، نسخهها و سفارشهای آزمایشگاه را مدیریت میکنند.',
|
||||||
'بخش سمت سرور سامانه مدیریت داخلی ایران ایمپلنت را برای یک کلینیک تخصصی دندانپزشکی توسعه دادم. کارکنان از طریق یک سامانه متمرکز، نوبتها، اتاقهای درمان، پرداختها، نسخهها و سفارشهای آزمایشگاه را مدیریت میکنند.',
|
caseStudy: {
|
||||||
|
introduction: 'یک سامانه داخلی متمرکز برای هماهنگی فرایندهای اجرایی کلینیک تخصصی دندانپزشکی.',
|
||||||
|
role: 'توسعهدهنده Backend',
|
||||||
|
targetUsers: 'کارکنان کلینیک دندانپزشکی',
|
||||||
|
status: 'تکمیلشده',
|
||||||
|
challenge: 'کارکنان کلینیک برای چند فرایند مرتبط به یک سامانه اجرایی نیاز داشتند؛ فرایندهایی که در حالت جداگانه به ثبت و هماهنگی بیشتری نیاز دارند.',
|
||||||
|
ownership: ['قابلیتهای سمت سرور برای مدیریت نوبت و اتاق درمان را توسعه دادم.', 'فرایندهای پرداخت و نسخه را پشتیبانی کردم.', 'مدیریت سفارشهای آزمایشگاه را در سامانه متمرکز پیادهسازی کردم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'عملیات متمرکز کلینیک', description: 'نوبتها، اتاقها، پرداختها، نسخهها و سفارشهای آزمایشگاه در یک جریان کاری مشترک برای کارکنان قرار گرفتند.' },
|
||||||
|
{ discipline: 'engineering', title: 'تحویل Backend مناسب محیط واقعی', description: 'برای توسعه سمت سرور از Django و در زیرساخت استقرار از Nginx و Docker استفاده شد.' },
|
||||||
|
],
|
||||||
|
outcomes: ['کارکنان کلینیک به یک پلتفرم برای مدیریت فرایندهای داخلی اصلی دسترسی پیدا کردند.', 'هماهنگی درمان و سوابق اداری در یک سامانه به هم متصل شد.'],
|
||||||
|
skills: ['توسعه Backend', 'مدلسازی فرایند', 'مهندسی استقرار'],
|
||||||
|
tools: ['Django', 'Nginx', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -102,18 +337,64 @@ const projectDefinitions: ProjectDefinition[] = [
|
|||||||
id: 'interanet-solutions',
|
id: 'interanet-solutions',
|
||||||
tags: ['Bash', 'Caddy', 'Docker', 'DevOps'],
|
tags: ['Bash', 'Caddy', 'Docker', 'DevOps'],
|
||||||
image: portfolioImage('/images/project-interanet-solutions.png', 1683, 935),
|
image: portfolioImage('/images/project-interanet-solutions.png', 1683, 935),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-interanet-solutions.png', 1683, 935),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Interanet Solutions interface in light mode', caption: 'The self-hosted solutions collection in light mode.' },
|
||||||
|
fa: { alt: 'رابط راهکارهای اینترانت در حالت روشن', caption: 'نمای مجموعه راهکارهای خودمیزبان در حالت روشن.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-interanet-solutions-dark.png', 1683, 935),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Interanet Solutions interface in dark mode', caption: 'The self-hosted solutions collection in dark mode.' },
|
||||||
|
fa: { alt: 'رابط راهکارهای اینترانت در حالت تیره', caption: 'نمای مجموعه راهکارهای خودمیزبان در حالت تیره.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Interanet Solutions',
|
title: 'Interanet Solutions',
|
||||||
date: 'MAR - JUN 2026',
|
date: 'MAR - JUN 2026',
|
||||||
description:
|
description: 'Developed a collection of self-hosted infrastructure and deployment solutions for environments with restricted international Internet access, including automated server bootstrap scripts and deployments for tools such as GitLab/Gitea (GitHub), Penpot (Figma), and Jitsi (Google Meet).',
|
||||||
'Developed a collection of self-hosted infrastructure and deployment solutions for environments with restricted international Internet access, including automated server bootstrap scripts and deployments for tools such as GitLab/Gitea (GitHub), Penpot (Figma), and Jitsi (Google Meet).',
|
caseStudy: {
|
||||||
|
introduction: 'Self-hosted infrastructure and automated deployment paths for teams working with restricted international Internet access.',
|
||||||
|
role: 'DevOps developer',
|
||||||
|
targetUsers: 'Organizations and technical teams operating restricted-network environments',
|
||||||
|
status: 'Completed',
|
||||||
|
challenge: 'Teams still need dependable collaboration, design, source control, and communication tools when international services are unavailable or unreliable.',
|
||||||
|
ownership: ['Developed automated server bootstrap scripts.', 'Prepared self-hosted deployments for source-control, design, and meeting tools.', 'Packaged repeatable infrastructure using containerized services.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'Practical service substitutes', description: 'Focused on recognizable alternatives: GitLab or Gitea for GitHub, Penpot for Figma, and Jitsi for Google Meet.' },
|
||||||
|
{ discipline: 'engineering', title: 'Automated, self-hosted delivery', description: 'Combined Bash automation, Caddy, and Docker to reduce manual server setup and keep deployments repeatable.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Collected multiple restricted-network deployment solutions into a reusable toolkit.', 'Reduced repeated manual setup through automated server bootstrap flows.'],
|
||||||
|
constraints: ['Designed for environments with restricted international Internet access.'],
|
||||||
|
skills: ['Infrastructure automation', 'Self-hosting', 'Deployment engineering'],
|
||||||
|
tools: ['Bash', 'Caddy', 'Docker', 'GitLab', 'Gitea', 'Penpot', 'Jitsi'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'راهکارهای اینترانت',
|
title: 'راهکارهای اینترانت',
|
||||||
date: 'اسفند ۱۴۰۴ تا خرداد ۱۴۰۵',
|
date: 'اسفند ۱۴۰۴ تا خرداد ۱۴۰۵',
|
||||||
description:
|
description: 'مجموعهای از راهکارهای زیرساخت و استقرار خودمیزبان برای محیطهایی با دسترسی محدود به اینترنت بینالمللی توسعه دادم. این مجموعه شامل اسکریپتهای خودکار راهاندازی اولیه سرور و استقرار ابزارهایی مانند GitLab و Gitea بهجای GitHub، Penpot بهجای Figma و Jitsi بهجای Google Meet است.',
|
||||||
'مجموعهای از راهکارهای زیرساخت و استقرار خودمیزبان برای محیطهایی با دسترسی محدود به اینترنت بینالمللی توسعه دادم. این مجموعه شامل اسکریپتهای خودکار راهاندازی اولیه سرور و استقرار ابزارهایی مانند GitLab و Gitea بهجای GitHub، Penpot بهجای Figma و Jitsi بهجای Google Meet است.',
|
caseStudy: {
|
||||||
|
introduction: 'زیرساخت خودمیزبان و مسیرهای استقرار خودکار برای تیمهایی که با محدودیت اینترنت بینالمللی کار میکنند.',
|
||||||
|
role: 'توسعهدهنده DevOps',
|
||||||
|
targetUsers: 'سازمانها و تیمهای فنی فعال در شبکههای محدود',
|
||||||
|
status: 'تکمیلشده',
|
||||||
|
challenge: 'وقتی سرویسهای بینالمللی در دسترس یا پایدار نیستند، تیمها همچنان به ابزارهای قابلاعتماد برای همکاری، طراحی، کنترل کد و ارتباط نیاز دارند.',
|
||||||
|
ownership: ['اسکریپتهای خودکار راهاندازی اولیه سرور را توسعه دادم.', 'استقرار خودمیزبان ابزارهای کنترل کد، طراحی و جلسه را آماده کردم.', 'زیرساخت تکرارپذیر را با سرویسهای کانتینری بستهبندی کردم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'جایگزینهای عملی برای سرویسها', description: 'تمرکز بر جایگزینهای شناختهشده بود: GitLab یا Gitea بهجای GitHub، Penpot بهجای Figma و Jitsi بهجای Google Meet.' },
|
||||||
|
{ discipline: 'engineering', title: 'تحویل خودکار و خودمیزبان', description: 'ترکیب Bash، Caddy و Docker راهاندازی دستی سرور را کاهش داد و استقرارها را تکرارپذیر نگه داشت.' },
|
||||||
|
],
|
||||||
|
outcomes: ['چند راهکار استقرار در شبکه محدود در یک مجموعه قابلاستفاده مجدد جمع شد.', 'راهاندازی خودکار اولیه، نیاز به تنظیمات دستی تکراری را کاهش داد.'],
|
||||||
|
constraints: ['طراحیشده برای محیطهایی با دسترسی محدود به اینترنت بینالمللی.'],
|
||||||
|
skills: ['خودکارسازی زیرساخت', 'میزبانی داخلی', 'مهندسی استقرار'],
|
||||||
|
tools: ['Bash', 'Caddy', 'Docker', 'GitLab', 'Gitea', 'Penpot', 'Jitsi'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -121,47 +402,160 @@ const projectDefinitions: ProjectDefinition[] = [
|
|||||||
id: 'postgresql-job-queue',
|
id: 'postgresql-job-queue',
|
||||||
tags: ['Go', 'SvelteKit', 'Docker'],
|
tags: ['Go', 'SvelteKit', 'Docker'],
|
||||||
image: portfolioImage('/images/project-postgresql-job-queue.png', 359, 200),
|
image: portfolioImage('/images/project-postgresql-job-queue.png', 359, 200),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-postgresql-job-queue.png', 359, 200),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'PostgreSQL Job Queue project interface', caption: 'The project overview for the PostgreSQL-backed job system.' },
|
||||||
|
fa: { alt: 'رابط پروژه صف کار PostgreSQL', caption: 'نمای کلی سیستم صف کار مبتنی بر PostgreSQL.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'PostgreSQL Job Queue',
|
title: 'PostgreSQL Job Queue',
|
||||||
date: 'JUN 2026',
|
date: 'JUN 2026',
|
||||||
description:
|
description: 'Built a lightweight background job system for running slow or unreliable tasks outside the main request flow. The project focuses on making job execution safe and predictable: multiple workers can pick up work at the same time without blocking each other, failed jobs are retried safely, and work remains recoverable.',
|
||||||
'Built a lightweight background job system for running slow or unreliable tasks outside the main request flow. The project focuses on making job execution safe and predictable: multiple workers can pick up work at the same time without blocking each other, failed jobs are retried safely, and work remains recoverable.',
|
caseStudy: {
|
||||||
|
introduction: 'A lightweight background job system built around safe concurrency, retries, and recoverability.',
|
||||||
|
role: 'Backend developer',
|
||||||
|
targetUsers: 'Application developers running slow or unreliable work outside request handling',
|
||||||
|
status: 'Completed',
|
||||||
|
challenge: 'Long-running or unreliable tasks should not block request handling, but moving them into workers introduces concurrency, failure, and recovery concerns.',
|
||||||
|
ownership: ['Built a background execution path outside the main request flow.', 'Supported multiple workers taking jobs without blocking one another.', 'Implemented safe retries and recoverable work handling.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'engineering', title: 'PostgreSQL-backed coordination', description: 'Used PostgreSQL as the durable coordination layer for a lightweight job queue.' },
|
||||||
|
{ discipline: 'engineering', title: 'Failure-aware execution', description: 'Treated retries and recovery as core behavior so failed or interrupted work stays predictable.' },
|
||||||
|
{ discipline: 'product', title: 'Small operational surface', description: 'Focused the system on the essential needs of background execution rather than a broad workflow platform.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Allowed slow and unreliable tasks to execute outside request handling.', 'Made concurrent worker pickup, retries, and recovery part of the system’s normal behavior.'],
|
||||||
|
skills: ['Concurrent systems', 'Failure handling', 'Backend architecture'],
|
||||||
|
tools: ['Go', 'PostgreSQL', 'SvelteKit', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'صف کار PostgreSQL',
|
title: 'صف کار PostgreSQL',
|
||||||
date: 'خرداد ۱۴۰۵',
|
date: 'خرداد ۱۴۰۵',
|
||||||
description:
|
description: 'یک سیستم سبک برای اجرای کارهای پسزمینه ساختم تا کارهای کند یا ناپایدار بیرون از مسیر اصلی درخواست اجرا شوند. چند پردازشگر میتوانند همزمان و بدون مسدودکردن یکدیگر کار بردارند، کارهای ناموفق با سازوکاری امن دوباره اجرا میشوند و در صورت وقفه قابلبازیابی میمانند.',
|
||||||
'یک سیستم سبک برای اجرای کارهای پسزمینه ساختم تا کارهای کند یا ناپایدار بیرون از مسیر اصلی درخواست اجرا شوند. چند پردازشگر میتوانند همزمان و بدون مسدودکردن یکدیگر کار بردارند، کارهای ناموفق با سازوکاری امن دوباره اجرا میشوند و در صورت وقفه قابلبازیابی میمانند.',
|
caseStudy: {
|
||||||
|
introduction: 'یک سیستم سبک برای اجرای کارهای پسزمینه با تمرکز بر همزمانی امن، تلاش مجدد و بازیابی.',
|
||||||
|
role: 'توسعهدهنده Backend',
|
||||||
|
targetUsers: 'توسعهدهندگانی که کارهای کند یا ناپایدار را بیرون از چرخه درخواست اجرا میکنند',
|
||||||
|
status: 'تکمیلشده',
|
||||||
|
challenge: 'کارهای طولانی یا ناپایدار نباید پاسخ درخواست را متوقف کنند؛ اما انتقال آنها به پردازشگرها، مسئله همزمانی، شکست و بازیابی را به همراه دارد.',
|
||||||
|
ownership: ['مسیر اجرای پسزمینه را بیرون از چرخه اصلی درخواست ساختم.', 'برداشتن همزمان کار توسط چند پردازشگر را بدون مسدودکردن یکدیگر پشتیبانی کردم.', 'تلاش مجدد امن و بازیابی کارها را پیادهسازی کردم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'engineering', title: 'هماهنگی مبتنی بر PostgreSQL', description: 'از PostgreSQL بهعنوان لایه پایدار هماهنگی برای یک صف کار سبک استفاده شد.' },
|
||||||
|
{ discipline: 'engineering', title: 'اجرای آگاه از شکست', description: 'تلاش مجدد و بازیابی بهعنوان رفتارهای اصلی در نظر گرفته شدند تا کار ناموفق یا متوقفشده قابلپیشبینی بماند.' },
|
||||||
|
{ discipline: 'product', title: 'سطح عملیاتی کوچک', description: 'تمرکز سیستم بر نیازهای اصلی اجرای پسزمینه باقی ماند و به یک پلتفرم گسترده گردش کار تبدیل نشد.' },
|
||||||
|
],
|
||||||
|
outcomes: ['کارهای کند و ناپایدار بیرون از مسیر پاسخگویی به درخواست اجرا شدند.', 'برداشت همزمان کار، تلاش مجدد و بازیابی به بخشی از رفتار عادی سیستم تبدیل شد.'],
|
||||||
|
skills: ['سیستمهای همزمان', 'مدیریت شکست', 'معماری Backend'],
|
||||||
|
tools: ['Go', 'PostgreSQL', 'SvelteKit', 'Docker'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subsource',
|
id: 'subsource',
|
||||||
link: 'http://subsource.net/',
|
liveUrl: 'http://subsource.net/',
|
||||||
tags: ['Product Design', 'Figma', 'Prototyping'],
|
tags: ['Product Design', 'Figma', 'Prototyping'],
|
||||||
image: portfolioImage('/images/project-subsource.png', 2560, 1440),
|
image: portfolioImage('/images/project-subsource.png', 2560, 1440),
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-subsource.png', 2560, 1440),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Subsource subtitle discovery product in light mode', caption: 'The subtitle discovery experience in light mode.' },
|
||||||
|
fa: { alt: 'محصول جستوجوی زیرنویس سابسورس در حالت روشن', caption: 'نمای تجربه جستوجوی زیرنویس در حالت روشن.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: portfolioImage('/images/project-subsource-dark.png', 2560, 1440),
|
||||||
|
content: {
|
||||||
|
en: { alt: 'Subsource subtitle discovery product in dark mode', caption: 'The subtitle discovery experience in dark mode.' },
|
||||||
|
fa: { alt: 'محصول جستوجوی زیرنویس سابسورس در حالت تیره', caption: 'نمای تجربه جستوجوی زیرنویس در حالت تیره.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Subsource',
|
title: 'Subsource',
|
||||||
date: 'MAY 2024',
|
date: 'MAY 2024',
|
||||||
description:
|
description: 'Designed the end-to-end UI/UX for SubSource, a subtitle discovery and download platform serving movie and TV audiences across multiple languages. Focused on simplifying content discovery, organizing information-heavy interfaces, and creating intuitive user flows across the product.',
|
||||||
'Designed the end-to-end UI/UX for SubSource, a subtitle discovery and download platform serving movie and TV audiences across multiple languages. Focused on simplifying content discovery, organizing information-heavy interfaces, and creating intuitive user flows across the product.',
|
caseStudy: {
|
||||||
|
introduction: 'An end-to-end product design for finding and downloading movie and television subtitles across languages.',
|
||||||
|
role: 'Product designer',
|
||||||
|
targetUsers: 'Movie and television audiences looking for subtitles in multiple languages',
|
||||||
|
status: 'Completed',
|
||||||
|
challenge: 'Subtitle discovery combines search, language choices, content metadata, and download decisions in an information-heavy interface.',
|
||||||
|
ownership: ['Designed the product’s end-to-end UI/UX.', 'Simplified content discovery across movies, television, and languages.', 'Organized dense information and created intuitive product flows.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'Discovery-first structure', description: 'Centered the experience on helping people identify the right title and subtitle efficiently.' },
|
||||||
|
{ discipline: 'design', title: 'Organized information density', description: 'Structured metadata and choices so a detailed interface remains scannable.' },
|
||||||
|
{ discipline: 'design', title: 'Consistent end-to-end flows', description: 'Connected discovery and download through a coherent interaction model across the product.' },
|
||||||
|
],
|
||||||
|
outcomes: ['Produced a complete UI/UX direction for a multilingual subtitle platform.', 'Turned an information-heavy discovery task into a clearer set of user flows.'],
|
||||||
|
skills: ['Product design', 'Information architecture', 'Interaction design', 'Prototyping'],
|
||||||
|
tools: ['Figma'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
title: 'سابسورس',
|
title: 'سابسورس',
|
||||||
date: 'اردیبهشت ۱۴۰۳',
|
date: 'اردیبهشت ۱۴۰۳',
|
||||||
description:
|
description: 'طراحی کامل رابط و تجربه کاربری پلتفرم سابسورس را انجام دادم؛ سرویسی برای پیدا کردن و دانلود زیرنویس فیلم و سریال به زبانهای مختلف. تمرکزم بر سادهکردن جستوجوی محتوا، مرتبکردن رابطهای پرجزئیات و طراحی مسیرهای کاربری روشن در سراسر محصول بود.',
|
||||||
'طراحی کامل رابط و تجربه کاربری پلتفرم سابسورس را انجام دادم؛ سرویسی برای پیدا کردن و دانلود زیرنویس فیلم و سریال به زبانهای مختلف. تمرکزم بر سادهکردن جستوجوی محتوا، مرتبکردن رابطهای پرجزئیات و طراحی مسیرهای کاربری روشن در سراسر محصول بود.',
|
caseStudy: {
|
||||||
|
introduction: 'طراحی کامل محصول برای پیدا کردن و دانلود زیرنویس فیلم و سریال به زبانهای مختلف.',
|
||||||
|
role: 'طراح محصول',
|
||||||
|
targetUsers: 'مخاطبان فیلم و سریال که بهدنبال زیرنویس در زبانهای مختلف هستند',
|
||||||
|
status: 'تکمیلشده',
|
||||||
|
challenge: 'پیدا کردن زیرنویس، جستوجو، انتخاب زبان، اطلاعات محتوا و تصمیم دانلود را در یک رابط پرجزئیات کنار هم قرار میدهد.',
|
||||||
|
ownership: ['طراحی کامل UI/UX محصول را انجام دادم.', 'پیدا کردن محتوا در میان فیلم، سریال و زبانهای مختلف را ساده کردم.', 'اطلاعات پرتراکم را سامان دادم و مسیرهای کاربری روشن ساختم.'],
|
||||||
|
decisions: [
|
||||||
|
{ discipline: 'product', title: 'ساختار مبتنی بر کشف محتوا', description: 'تجربه حول شناسایی سریع عنوان و زیرنویس مناسب شکل گرفت.' },
|
||||||
|
{ discipline: 'design', title: 'ساماندهی تراکم اطلاعات', description: 'فراداده و انتخابها طوری ساختاربندی شدند که رابط پرجزئیات همچنان قابلاسکن بماند.' },
|
||||||
|
{ discipline: 'design', title: 'جریان یکپارچه از ابتدا تا انتها', description: 'کشف محتوا و دانلود با یک مدل تعاملی منسجم در سراسر محصول به هم متصل شدند.' },
|
||||||
|
],
|
||||||
|
outcomes: ['یک مسیر کامل UI/UX برای پلتفرم چندزبانه زیرنویس طراحی شد.', 'وظیفه پرجزئیات یافتن زیرنویس به مجموعهای روشنتر از مسیرهای کاربری تبدیل شد.'],
|
||||||
|
skills: ['طراحی محصول', 'معماری اطلاعات', 'طراحی تعامل', 'نمونهسازی'],
|
||||||
|
tools: ['Figma'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const projectIds = projectDefinitions.map(({ id }) => id);
|
||||||
|
|
||||||
export const getProjects = (locale: Locale): Project[] =>
|
export const getProjects = (locale: Locale): Project[] =>
|
||||||
projectDefinitions.map(({ content, ...project }) => ({
|
projectDefinitions.map(({ content, gallery, ...project }) => ({
|
||||||
...project,
|
...project,
|
||||||
...content[locale],
|
...content[locale],
|
||||||
|
caseStudy: {
|
||||||
|
...content[locale].caseStudy,
|
||||||
|
gallery: gallery.map(({ image, content: galleryContent }) => ({
|
||||||
|
image,
|
||||||
|
...galleryContent[locale],
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const getProject = (locale: Locale, id: string) =>
|
||||||
|
getProjects(locale).find((project) => project.id === id);
|
||||||
|
|
||||||
|
export const getAdjacentProjects = (locale: Locale, id: string) => {
|
||||||
|
const localizedProjects = getProjects(locale);
|
||||||
|
const currentIndex = localizedProjects.findIndex((project) => project.id === id);
|
||||||
|
if (currentIndex < 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: localizedProjects[(currentIndex - 1 + localizedProjects.length) % localizedProjects.length],
|
||||||
|
next: localizedProjects[(currentIndex + 1) % localizedProjects.length],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPortfolioPath = (locale: Locale) => (locale === 'fa' ? '/fa' : '/');
|
||||||
|
|
||||||
|
export const getProjectPath = (locale: Locale, id: string) =>
|
||||||
|
`${locale === 'fa' ? '/fa' : ''}/projects/${id}`;
|
||||||
|
|
||||||
export const projects = getProjects('en');
|
export const projects = getProjects('en');
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ export interface SiteMessages {
|
|||||||
carouselRoleDescription: string;
|
carouselRoleDescription: string;
|
||||||
scrollbarLabel: string;
|
scrollbarLabel: string;
|
||||||
viewCaseStudy: string;
|
viewCaseStudy: string;
|
||||||
|
liveProjectLabel: (title: string) => string;
|
||||||
|
drawerLabel: string;
|
||||||
|
closeCaseStudy: string;
|
||||||
|
copyLink: string;
|
||||||
|
linkCopied: string;
|
||||||
|
copyFailed: string;
|
||||||
|
roleLabel: string;
|
||||||
|
usersLabel: string;
|
||||||
|
statusLabel: string;
|
||||||
|
teamLabel: string;
|
||||||
|
galleryLabel: string;
|
||||||
|
challengeLabel: string;
|
||||||
|
ownershipLabel: string;
|
||||||
|
decisionsLabel: string;
|
||||||
|
outcomesLabel: string;
|
||||||
|
skillsLabel: string;
|
||||||
|
toolsLabel: string;
|
||||||
|
constraintsLabel: string;
|
||||||
|
metricsLabel: string;
|
||||||
|
lessonsLabel: string;
|
||||||
|
previousCaseStudy: string;
|
||||||
|
nextCaseStudy: string;
|
||||||
|
decisionLabels: Record<'product' | 'engineering' | 'design', string>;
|
||||||
};
|
};
|
||||||
experience: {
|
experience: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -173,6 +196,33 @@ export const messagesByLocale = {
|
|||||||
carouselRoleDescription: 'carousel',
|
carouselRoleDescription: 'carousel',
|
||||||
scrollbarLabel: 'Project carousel position',
|
scrollbarLabel: 'Project carousel position',
|
||||||
viewCaseStudy: 'View case study',
|
viewCaseStudy: 'View case study',
|
||||||
|
liveProjectLabel: (title) => `Open the live ${title} project in a new tab`,
|
||||||
|
drawerLabel: 'Project case study',
|
||||||
|
closeCaseStudy: 'Close case study',
|
||||||
|
copyLink: 'Copy link',
|
||||||
|
linkCopied: 'Link copied',
|
||||||
|
copyFailed: 'Could not copy the link',
|
||||||
|
roleLabel: 'Role',
|
||||||
|
usersLabel: 'Users',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
teamLabel: 'Team',
|
||||||
|
galleryLabel: 'Product gallery',
|
||||||
|
challengeLabel: 'The challenge',
|
||||||
|
ownershipLabel: 'My ownership',
|
||||||
|
decisionsLabel: 'Key decisions',
|
||||||
|
outcomesLabel: 'Outcomes',
|
||||||
|
skillsLabel: 'Skills',
|
||||||
|
toolsLabel: 'Tools',
|
||||||
|
constraintsLabel: 'Constraints',
|
||||||
|
metricsLabel: 'Results',
|
||||||
|
lessonsLabel: 'Lessons',
|
||||||
|
previousCaseStudy: 'Previous project',
|
||||||
|
nextCaseStudy: 'Next project',
|
||||||
|
decisionLabels: {
|
||||||
|
product: 'Product',
|
||||||
|
engineering: 'Engineering',
|
||||||
|
design: 'Design',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
experience: {
|
experience: {
|
||||||
title: "Where I've Made an Impact",
|
title: "Where I've Made an Impact",
|
||||||
@@ -291,6 +341,33 @@ export const messagesByLocale = {
|
|||||||
carouselRoleDescription: 'گردونه',
|
carouselRoleDescription: 'گردونه',
|
||||||
scrollbarLabel: 'موقعیت در فهرست پروژهها',
|
scrollbarLabel: 'موقعیت در فهرست پروژهها',
|
||||||
viewCaseStudy: 'دیدن مطالعه موردی',
|
viewCaseStudy: 'دیدن مطالعه موردی',
|
||||||
|
liveProjectLabel: (title) => `باز کردن نسخه زنده ${title} در زبانه جدید`,
|
||||||
|
drawerLabel: 'مطالعه موردی پروژه',
|
||||||
|
closeCaseStudy: 'بستن مطالعه موردی',
|
||||||
|
copyLink: 'کپی پیوند',
|
||||||
|
linkCopied: 'پیوند کپی شد',
|
||||||
|
copyFailed: 'کپی پیوند ممکن نشد',
|
||||||
|
roleLabel: 'نقش من',
|
||||||
|
usersLabel: 'کاربران',
|
||||||
|
statusLabel: 'وضعیت',
|
||||||
|
teamLabel: 'تیم',
|
||||||
|
galleryLabel: 'تصاویر محصول',
|
||||||
|
challengeLabel: 'چالش',
|
||||||
|
ownershipLabel: 'مسئولیتهای من',
|
||||||
|
decisionsLabel: 'تصمیمهای کلیدی',
|
||||||
|
outcomesLabel: 'نتیجهها',
|
||||||
|
skillsLabel: 'مهارتها',
|
||||||
|
toolsLabel: 'ابزارها',
|
||||||
|
constraintsLabel: 'محدودیتها',
|
||||||
|
metricsLabel: 'نتیجههای عددی',
|
||||||
|
lessonsLabel: 'آموختهها',
|
||||||
|
previousCaseStudy: 'پروژه قبلی',
|
||||||
|
nextCaseStudy: 'پروژه بعدی',
|
||||||
|
decisionLabels: {
|
||||||
|
product: 'محصول',
|
||||||
|
engineering: 'مهندسی',
|
||||||
|
design: 'طراحی',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
experience: {
|
experience: {
|
||||||
title: 'جاهایی که اثر گذاشتهام',
|
title: 'جاهایی که اثر گذاشتهام',
|
||||||
|
|||||||
70
tests/project-case-studies.test.mjs
Normal file
70
tests/project-case-studies.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { access, readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const routeFiles = [
|
||||||
|
'src/app/(en)/projects/layout.tsx',
|
||||||
|
'src/app/(en)/projects/[projectId]/page.tsx',
|
||||||
|
'src/app/(fa)/fa/projects/layout.tsx',
|
||||||
|
'src/app/(fa)/fa/projects/[projectId]/page.tsx',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('localized project routes are statically generated and reject unknown slugs', async () => {
|
||||||
|
await Promise.all(routeFiles.map((file) => access(file)));
|
||||||
|
|
||||||
|
const pages = (
|
||||||
|
await Promise.all(
|
||||||
|
routeFiles
|
||||||
|
.filter((file) => file.endsWith('page.tsx'))
|
||||||
|
.map((file) => readFile(file, 'utf8')),
|
||||||
|
)
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
assert.match(pages, /generateStaticParams/);
|
||||||
|
assert.match(pages, /dynamicParams\s*=\s*false/);
|
||||||
|
assert.match(pages, /await params/);
|
||||||
|
assert.match(pages, /generateMetadata/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every project exposes an evidence-based localized case study', async () => {
|
||||||
|
const source = await readFile('src/data/projects.ts', 'utf8');
|
||||||
|
|
||||||
|
assert.doesNotMatch(source, /\blink\??:/);
|
||||||
|
assert.doesNotMatch(source, /\blink:/);
|
||||||
|
assert.ok((source.match(/\bcaseStudy:/g) ?? []).length >= 14);
|
||||||
|
assert.equal((source.match(/\bgallery:\s*\[/g) ?? []).length, 7);
|
||||||
|
assert.match(source, /export const getProject\s*=/);
|
||||||
|
assert.match(source, /export const getAdjacentProjects\s*=/);
|
||||||
|
assert.match(source, /export const getProjectPath\s*=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project cards separate case study navigation from live links', async () => {
|
||||||
|
const source = await readFile('src/components/ProjectsSection.tsx', 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /import Link from ['"]next\/link['"]/);
|
||||||
|
assert.match(source, /scroll=\{false\}/);
|
||||||
|
assert.match(source, /target="_blank"/);
|
||||||
|
assert.match(source, /rel="noopener noreferrer"/);
|
||||||
|
assert.match(source, /project\.liveUrl/);
|
||||||
|
assert.match(source, /caseStudyInput/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the case study drawer has accessible and pointer-safe dismissal paths', async () => {
|
||||||
|
const [component, styles] = await Promise.all([
|
||||||
|
Promise.all([
|
||||||
|
'src/components/CaseStudyDrawer.tsx',
|
||||||
|
'src/components/useDrawerDrag.ts',
|
||||||
|
].map((file) => readFile(file, 'utf8'))).then((files) => files.join('\n')),
|
||||||
|
readFile('src/components/CaseStudyDrawer.module.css', 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(component, /showModal\(\)/);
|
||||||
|
assert.match(component, /onCancel=/);
|
||||||
|
assert.match(component, /setPointerCapture/);
|
||||||
|
assert.match(component, /navigator\.clipboard/);
|
||||||
|
assert.match(component, /aria-live="polite"/);
|
||||||
|
assert.match(styles, /::backdrop/);
|
||||||
|
assert.match(styles, /env\(safe-area-inset-bottom/);
|
||||||
|
assert.match(styles, /prefers-reduced-motion:\s*reduce/);
|
||||||
|
assert.doesNotMatch(styles, /transition:\s*all/);
|
||||||
|
});
|
||||||
@@ -61,6 +61,28 @@ test('the standalone server renders both locales and optimizes local images', as
|
|||||||
assert.match(persianHtml, /<html[^>]*lang="fa"[^>]*dir="rtl"/i);
|
assert.match(persianHtml, /<html[^>]*lang="fa"[^>]*dir="rtl"/i);
|
||||||
assert.match(persianHtml, /سلام، من امیرحسین خلیلی هستم/);
|
assert.match(persianHtml, /سلام، من امیرحسین خلیلی هستم/);
|
||||||
|
|
||||||
|
const projectIds = [
|
||||||
|
'qlockify',
|
||||||
|
'guilan-ace',
|
||||||
|
'zarinpal',
|
||||||
|
'iran-implant',
|
||||||
|
'interanet-solutions',
|
||||||
|
'postgresql-job-queue',
|
||||||
|
'subsource',
|
||||||
|
];
|
||||||
|
const projectRoutes = projectIds.flatMap((id) => [`/projects/${id}`, `/fa/projects/${id}`]);
|
||||||
|
const projectResponses = await Promise.all(projectRoutes.map((route) => fetch(`${origin}${route}`)));
|
||||||
|
for (const response of projectResponses) assert.equal(response.status, 200);
|
||||||
|
const projectHtml = await Promise.all(projectResponses.map((response) => response.text()));
|
||||||
|
for (let index = 0; index < projectHtml.length; index += 1) {
|
||||||
|
assert.match(projectHtml[index], /<dialog[^>]*aria-labelledby="case-study-title"/i);
|
||||||
|
assert.match(projectHtml[index], /id="case-study-title"/i);
|
||||||
|
assert.match(projectHtml[index], /Qlockify|Guilan|زرین|Implant|ایمپلنت|Interanet|اینترانت|PostgreSQL|Subsource|سابسورس/i);
|
||||||
|
assert.match(projectHtml[index], index % 2 === 0 ? /<html[^>]*dir="ltr"/i : /<html[^>]*dir="rtl"/i);
|
||||||
|
}
|
||||||
|
assert.equal((await fetch(`${origin}/projects/not-a-project`)).status, 404);
|
||||||
|
assert.equal((await fetch(`${origin}/fa/projects/not-a-project`)).status, 404);
|
||||||
|
|
||||||
const staticAsset = englishHtml.match(/(?:src|href)="(\/_next\/static\/[^"?#]+)/)?.[1];
|
const staticAsset = englishHtml.match(/(?:src|href)="(\/_next\/static\/[^"?#]+)/)?.[1];
|
||||||
assert.ok(staticAsset, 'expected a built static asset');
|
assert.ok(staticAsset, 'expected a built static asset');
|
||||||
assert.equal((await fetch(`${origin}${staticAsset}`)).status, 200);
|
assert.equal((await fetch(`${origin}${staticAsset}`)).status, 200);
|
||||||
|
|||||||
Reference in New Issue
Block a user