diff --git a/public/images/portfolio-placeholder.svg b/public/images/portfolio-placeholder.svg new file mode 100644 index 0000000..d9e172f --- /dev/null +++ b/public/images/portfolio-placeholder.svg @@ -0,0 +1 @@ + diff --git a/src/content/contracts.ts b/src/content/contracts.ts new file mode 100644 index 0000000..c68b754 --- /dev/null +++ b/src/content/contracts.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +export const sectionKeys = ['hero', 'about', 'projects', 'experience', 'skills', 'education', 'milestones', 'footer'] as const; +export const entryKinds = ['projects', 'experience', 'skills', 'education', 'milestones'] as const; +const text = z.string().max(20000); +const title = z.string().min(1).max(300); +export const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(100); +export const linkSchema = z.string().max(2048).refine(value => { + if (!value) return true; + if (/^\/media\/[a-zA-Z0-9.-]+$/.test(value)) return true; + try { return ['https:', 'http:', 'mailto:', 'tel:'].includes(new URL(value).protocol); } catch { return false; } +}, 'Use an https, http, mailto, tel, or uploaded-media URL'); +export const imageSchema = z.object({ + src: z.string().regex(/^\/(?:images|media)\/[a-zA-Z0-9/_().-]+$/).refine(value => !value.includes('..'), 'Invalid image path'), + width: z.number().int().min(1).max(20000), height: z.number().int().min(1).max(20000), +}).strict(); +const strings = z.array(text).max(200); +const gallery = z.array(z.object({ image: imageSchema, alt: text, caption: text }).strict()).max(100); +export const entrySchemas = { + projects: z.object({ + title, liveUrl: linkSchema, date: text, description: text, tags: strings, image: imageSchema, + caseStudy: z.object({ + introduction: text, role: text, targetUsers: text, status: text, challenge: text, + ownership: strings, decisions: z.array(z.object({ discipline: z.enum(['product', 'engineering', 'design']), title, description: text }).strict()).max(100), + outcomes: strings, skills: strings, tools: strings, team: text, constraints: strings, + metrics: z.array(z.object({ value: text, label: text }).strict()).max(100), lessons: strings, gallery, + }).strict(), + }).strict(), + experience: z.object({ role: title, company: title, date: text, location: text, logo: imageSchema, isCurrent: z.boolean(), summary: text, bullets: strings }).strict(), + education: z.object({ degree: title, institution: title, date: text, description: text, logo: imageSchema }).strict(), + milestones: z.object({ title, issuer: text, date: text, description: text, image: imageSchema, link: linkSchema }).strict(), + skills: z.object({ label: title, skills: z.array(z.object({ name: title, level: z.number().int().min(0).max(5) }).strict()).max(100), keywords: strings, tools: z.array(z.object({ id: slugSchema, name: title, icon: imageSchema }).strict()).max(100) }).strict(), +}; +export const sectionSchemas = { + hero: z.object({ sectionLabel: text, titleLabel: text, greeting: text, name: title, subtitle: text, avatarAlt: text }).strict(), + about: z.object({ eyebrow: text, title, description: text, steps: z.array(z.object({ title, description: text }).strict()).max(20) }).strict(), + projects: z.object({ eyebrow: text, title }).strict(), + experience: z.object({ title }).strict(), + skills: z.object({ eyebrow: text, title }).strict(), + education: z.object({ title }).strict(), + milestones: z.object({ eyebrow: text, title }).strict(), + footer: z.object({ title, subtitle: text }).strict(), +}; +export const settingsSchema = z.object({ + defaultLanguage: z.string().min(2).max(35), avatar: imageSchema, + contactUrl: linkSchema, blogUrl: linkSchema, coursesUrl: linkSchema, email: z.union([z.email(), z.literal('')]), + contacts: z.array(z.object({ id: z.enum(['phone', 'linkedin', 'github', 'telegram']), label: text, href: linkSchema }).strict()).max(4) + .refine(items => new Set(items.map(item => item.id)).size === items.length, 'Contact types must be unique'), + metadata: z.record(z.string(), z.object({ title, description: text, keywords: strings }).strict()), +}).strict(); +export const languageSchema = z.object({ + code: z.string().min(2).max(35).refine(value => { + try { return Intl.getCanonicalLocales(value)[0].toLowerCase() === value && !['admin','api','media','projects'].includes(value); } catch { return false; } + }, 'Use a lowercase language tag, such as en, fa, or pt-br'), + name: title, direction: z.enum(['ltr', 'rtl']), enabled: z.boolean(), + messages: z.record(z.string().max(150), z.string().max(2000)), +}).strict(); +export const entrySchema = z.object({ + id: slugSchema, kind: z.enum(entryKinds), position: z.number().int().min(0).max(100000), visible: z.boolean(), + shared: z.record(z.string(), z.unknown()), translations: z.record(z.string(), z.record(z.string(), z.unknown())), +}).strict(); +export const sectionSchema = z.object({ + key: z.enum(sectionKeys), visible: z.boolean(), translations: z.record(z.string(), z.record(z.string(), z.unknown())), +}).strict(); +export type Settings = z.infer; +export type Language = z.infer; +export type Entry = z.infer; +export type Section = z.infer; +export type EntryKind = typeof entryKinds[number]; +export type SectionKey = typeof sectionKeys[number]; +export type EntryData = z.infer<(typeof entrySchemas)[K]> & { id: string }; + +export function formatMessage(template: string, values: Record, locale = 'en') { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + const value = values[key]; + return value === undefined ? match : typeof value === 'number' ? new Intl.NumberFormat(locale).format(value) : value; + }); +} +export function portfolioPath(locale: string, defaultLanguage: string) { return locale === defaultLanguage ? '/' : `/${locale}`; } +export function projectPath(locale: string, id: string, defaultLanguage: string) { return `${locale === defaultLanguage ? '' : `/${locale}`}/projects/${id}`; } diff --git a/src/content/messages.en.json b/src/content/messages.en.json new file mode 100644 index 0000000..16d9e45 --- /dev/null +++ b/src/content/messages.en.json @@ -0,0 +1,99 @@ +{ + "notFound": { + "code": "404", + "title": "This page isn’t here", + "description": "The address may have changed, or the page may no longer exist.", + "backHome": "Back to portfolio" + }, + "hero": { + "themeLightLabel": "Switch to light mode", + "themeDarkLabel": "Switch to dark mode", + "languageButtonLabel": "Choose language", + "languageMenuLabel": "Languages", + "englishLabel": "English", + "persianLabel": "فارسی", + "blog": "Blog", + "blogLabel": "Blog, coming soon", + "courses": "Courses", + "coursesLabel": "Courses, coming soon", + "comingSoon": "Coming soon", + "contact": "Get in touch", + "contactLabel": "Get in touch" + }, + "about": { + "journeyLabel": "Career journey" + }, + "projects": { + "previousLabel": "Previous projects", + "nextLabel": "Next projects", + "carouselLabel": "Scrollable project cards", + "carouselRoleDescription": "carousel", + "scrollbarLabel": "Project carousel position", + "viewCaseStudy": "View case study", + "liveProjectLabel": "Open the live {title} project in a new tab", + "drawerLabel": "Project case study", + "closeCaseStudy": "Close case study", + "shareLabel": "Share", + "shareCaseStudy": "Share this case study", + "shareDialogTitle": "Share case study", + "shareDialogDescription": "Send this project to someone or copy its direct link.", + "closeShareDialog": "Close sharing options", + "shareTelegram": "Telegram", + "shareWhatsApp": "WhatsApp", + "shareLinkedIn": "LinkedIn", + "shareEmail": "Email", + "copyLink": "Copy link", + "linkCopied": "Link copied", + "copyFailed": "Could not copy the link", + "roleLabel": "Role", + "usersLabel": "Users", + "statusLabel": "Status", + "teamLabel": "Team", + "galleryLabel": "Product gallery", + "openGalleryImage": "Open full-screen image: {caption}", + "selectGalleryImage": "Show image {index}: {caption}", + "imageViewerLabel": "Full-screen project gallery", + "closeImageViewer": "Close full-screen image", + "previousImage": "Previous image", + "nextImage": "Next image", + "imageCounter": "{current} of {total}", + "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": { + "logoAlt": "{company} logo" + }, + "skills": { + "skillsLabel": "SKILLS", + "toolsLabel": "TOOLS", + "filterLabel": "Choose skill discipline" + }, + "education": {}, + "milestones": { + "viewCertificate": "View certificate", + "viewCertificateLabel": "View {title} certificate" + }, + "footer": { + "emailLabel": "Send email", + "contactsLabel": "Social and contact links", + "sceneLabel": "Interactive 3D isometric footer scene", + "phone": "Phone", + "linkedin": "LinkedIn", + "github": "GitHub", + "telegram": "Telegram" + } +} diff --git a/src/content/messages.fa.json b/src/content/messages.fa.json new file mode 100644 index 0000000..5931e4f --- /dev/null +++ b/src/content/messages.fa.json @@ -0,0 +1,99 @@ +{ + "notFound": { + "code": "۴۰۴", + "title": "این صفحه پیدا نشد", + "description": "ممکن است نشانی صفحه تغییر کرده باشد یا دیگر در دسترس نباشد.", + "backHome": "بازگشت به نمونه‌کارها" + }, + "hero": { + "themeLightLabel": "رفتن به حالت روشن", + "themeDarkLabel": "رفتن به حالت تاریک", + "languageButtonLabel": "انتخاب زبان", + "languageMenuLabel": "زبان‌ها", + "englishLabel": "English", + "persianLabel": "فارسی", + "blog": "وبلاگ", + "blogLabel": "وبلاگ، به‌زودی", + "courses": "دوره‌ها", + "coursesLabel": "دوره‌ها، به‌زودی", + "comingSoon": "به‌زودی", + "contact": "ارتباط با من", + "contactLabel": "ارسال پیام در لینکدین" + }, + "about": { + "journeyLabel": "مسیر حرفه‌ای" + }, + "projects": { + "previousLabel": "پروژه‌های قبلی", + "nextLabel": "پروژه‌های بعدی", + "carouselLabel": "فهرست افقی پروژه‌ها", + "carouselRoleDescription": "گردونه", + "scrollbarLabel": "موقعیت در فهرست پروژه‌ها", + "viewCaseStudy": "دیدن مطالعه موردی", + "liveProjectLabel": "باز کردن نسخه زنده {title} در زبانه جدید", + "drawerLabel": "مطالعه موردی پروژه", + "closeCaseStudy": "بستن مطالعه موردی", + "shareLabel": "اشتراک‌گذاری", + "shareCaseStudy": "اشتراک‌گذاری این مطالعه موردی", + "shareDialogTitle": "اشتراک‌گذاری مطالعه موردی", + "shareDialogDescription": "این پروژه را برای دیگران بفرستید یا پیوند مستقیم آن را کپی کنید.", + "closeShareDialog": "بستن گزینه‌های اشتراک‌گذاری", + "shareTelegram": "تلگرام", + "shareWhatsApp": "واتس‌اپ", + "shareLinkedIn": "لینکدین", + "shareEmail": "ایمیل", + "copyLink": "کپی پیوند", + "linkCopied": "پیوند کپی شد", + "copyFailed": "کپی پیوند ممکن نشد", + "roleLabel": "نقش من", + "usersLabel": "کاربران", + "statusLabel": "وضعیت", + "teamLabel": "تیم", + "galleryLabel": "تصاویر محصول", + "openGalleryImage": "باز کردن تصویر در نمای تمام‌صفحه: {caption}", + "selectGalleryImage": "نمایش تصویر {index}: {caption}", + "imageViewerLabel": "گالری تمام‌صفحه پروژه", + "closeImageViewer": "بستن تصویر تمام‌صفحه", + "previousImage": "تصویر قبلی", + "nextImage": "تصویر بعدی", + "imageCounter": "{current} از {total}", + "challengeLabel": "چالش", + "ownershipLabel": "مسئولیت‌های من", + "decisionsLabel": "تصمیم‌های کلیدی", + "outcomesLabel": "نتیجه‌ها", + "skillsLabel": "مهارت‌ها", + "toolsLabel": "ابزارها", + "constraintsLabel": "محدودیت‌ها", + "metricsLabel": "نتیجه‌های عددی", + "lessonsLabel": "آموخته‌ها", + "previousCaseStudy": "پروژه قبلی", + "nextCaseStudy": "پروژه بعدی", + "decisionLabels": { + "product": "محصول", + "engineering": "مهندسی", + "design": "طراحی" + } + }, + "experience": { + "logoAlt": "نشان {company}" + }, + "skills": { + "skillsLabel": "مهارت‌ها", + "toolsLabel": "ابزارها", + "filterLabel": "انتخاب حوزه مهارت" + }, + "education": {}, + "milestones": { + "viewCertificate": "دیدن گواهی", + "viewCertificateLabel": "دیدن گواهی {title}" + }, + "footer": { + "emailLabel": "ارسال ایمیل", + "contactsLabel": "راه‌های ارتباطی و شبکه‌های اجتماعی", + "sceneLabel": "نمای تعاملی سه‌بعدی راه‌های ارتباطی", + "phone": "تلفن", + "linkedin": "لینکدین", + "github": "گیت‌هاب", + "telegram": "تلگرام" + } +} diff --git a/src/content/messages.ts b/src/content/messages.ts new file mode 100644 index 0000000..0ca31dd --- /dev/null +++ b/src/content/messages.ts @@ -0,0 +1,22 @@ +import english from './messages.en.json'; +import persian from './messages.fa.json'; +export type InterfaceMessages = typeof english; +export const dictionaries: Record = { en: english, fa: persian }; +export function flattenMessages(value: Record, prefix = ''): Record { + return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof item === 'string' ? [[path, item]] : Object.entries(flattenMessages(item as Record, path)); + })); +} +export const messageDefaults = flattenMessages(english); +export function resolveMessages(locale: string, overrides: Record): InterfaceMessages { + const result = structuredClone(dictionaries[locale] ?? english); + for (const [path, value] of Object.entries(overrides)) { + if (!Object.hasOwn(messageDefaults, path) || !value) continue; + const [section, field, child] = path.split('.'); + const group = result[section as keyof InterfaceMessages] as Record; + if (child) (group[field] as Record)[child] = value; + else group[field] = value; + } + return result; +} diff --git a/src/content/samples.ts b/src/content/samples.ts new file mode 100644 index 0000000..05db250 --- /dev/null +++ b/src/content/samples.ts @@ -0,0 +1,21 @@ +import type { EntryKind, Entry, Section, Settings, SectionKey } from './contracts'; +export const sampleImage = { src: '/images/portfolio-placeholder.svg', width: 640, height: 400 }; +export const entrySamples = { + projects: { title: 'A meaningful project', liveUrl: '', date: '2026', description: 'Describe the problem you solved and the people you helped.', tags: ['Design', 'Development'], image: sampleImage, + caseStudy: { introduction: 'Share the story behind your work.', role: 'Designer and developer', targetUsers: 'Your audience', status: 'Complete', challenge: 'What needed to change?', ownership: [], decisions: [], outcomes: [], skills: [], tools: [], team: '', constraints: [], metrics: [], lessons: [], gallery: [] } }, + experience: { role: 'Your role', company: 'Organization', date: '2024 – Present', location: 'Remote', logo: sampleImage, isCurrent: true, summary: 'Describe your contribution.', bullets: [] }, + education: { degree: 'Your qualification', institution: 'Institution', date: '2024', description: 'What did you learn?', logo: sampleImage }, + milestones: { title: 'A milestone worth sharing', issuer: 'Organization', date: '2026', description: 'Describe your achievement.', image: sampleImage, link: '' }, + skills: { label: 'My expertise', skills: [{ name: 'Problem solving', level: 4 }], keywords: ['Collaboration'], tools: [] }, +} satisfies Record>; +export const sectionSamples = { + hero: { sectionLabel: 'Introduction', titleLabel: 'Your name — portfolio', greeting: 'Hello, I’m', name: 'Your Name', subtitle: 'I make thoughtful things for people.', avatarAlt: 'Profile image' }, + about: { eyebrow: 'My journey', title: 'A little about me', description: 'Tell visitors what matters to you and how you approach your work.', steps: [{ title: 'Where it began', description: 'Share a chapter in your journey.' }] }, + projects: { eyebrow: 'Selected work', title: 'Things I have built' }, + experience: { title: 'Where I have contributed' }, skills: { eyebrow: 'My toolkit', title: 'Skills and tools' }, + education: { title: 'What I have learned' }, milestones: { eyebrow: 'Along the way', title: 'Milestones' }, + footer: { title: 'Let’s make something meaningful.', subtitle: 'Get in touch to discuss your next project.' }, +} satisfies Record>; +export const sampleSettings: Settings = { defaultLanguage: 'en', avatar: sampleImage, contactUrl: 'mailto:hello@example.com', blogUrl: '', coursesUrl: '', email: 'hello@example.com', contacts: [], metadata: { en: { title: 'Your Name — Portfolio', description: 'A selection of my work and experience.', keywords: [] } } }; +export function sampleEntries(): Entry[] { return Object.entries(entrySamples).map(([kind, data], position) => ({ id: `sample-${kind}`, kind: kind as EntryKind, position, visible: true, shared: data, translations: {} })); } +export function sampleSections(): Section[] { return Object.entries(sectionSamples).map(([key, data]) => ({ key: key as SectionKey, visible: true, translations: { en: data } })); }