A multi-page portfolio built with Next.js (App Router), React, TypeScript and Tailwind CSS. Content lives in typed data files, separate from the components that display it.
src/components/ui), hand-written because the shadcn CLI was not available while scaffolding/, /about, /projects, /projects/[slug], /technologies (old /stacks redirects here), /journey, /blog, /blog/[slug], /gallery, /certificates, /reviews, /contact
src/app/ routes, metadata, sitemap, robots, API routes (api/reviews, api/contact)
src/components/ layout (header, mobile nav, footer), ui primitives, one folder per feature
src/data/ all content: site, about, socials, navigation, projects, technologies, timeline, gallery, certificates, reviews
src/lib/ helpers: validation, blog loader, reviews, rate limiting, data integrity checks
content/blog/ Markdown posts
supabase/schema.sql reviews table with Row Level Security
public/images/ portrait and project images
npm install
npm run dev # http://localhost:3000
npm run lint
npm run typecheck
npm run build && npm start
Site details, email, availability badge, default theme: src/data/site.ts.
themeDefault: "dark" always starts dark; "system" follows the visitor’s OS when nothing is saved.
Add a project: add an object to src/data/projects.ts. Projects are grouped as Web Applications, Desktop Applications,
Automation & AI or Interactive Applications; featured: true adds it to the Featured tab and the home page.
There are no beginner/intermediate/advanced labels.
Project images: every project has a themed SVG placeholder (public/images/projects/<slug>-placeholder.svg) used on its card and at
the top of its page. Real screenshots go in the project’s screenshots list in src/data/projects.ts and appear under
“Screenshots” on the project page (click one to open it full size). Add fit: "contain" for small or unusually shaped screenshots.
GitHub language statistics: languageStats holds the percentages GitHub reports for the repository. They describe code,
not skill, and are labelled that way on every page. Each set must total 100 (the build fails otherwise). They are static
values; refresh them by copying the numbers from the repository’s Languages bar. Leave languageStats out when unknown.
Technologies: src/data/technologies.ts. Only proficiency: "core" (Python, JavaScript) and "good" (React,
TypeScript) are set. Do not add scores for other technologies unless you are confident in them.
Blog posts: copy content/blog/_post-template.md to a new .md file and fill in the front matter. The file name becomes the permanent article URL (/blog/<file-name>), and every article page includes Copy link, native Share and WhatsApp/X/LinkedIn share links. Set NEXT_PUBLIC_SITE_URL so the social share links and metadata use your real domain.
Certificates, gallery, timeline: src/data/certificates.ts, src/data/gallery.ts, src/data/timeline.ts.
Mobile uses a two-column project grid with compact cards (clamped text, small tags, icon links), tablets use three columns
and large screens use four, with the page container and spacing growing at each step. Headings use fluid clamp() sizes
defined in tailwind.config.ts. The mobile menu has an animated toggle, closes on link tap, outside tap and Escape,
locks page scroll, keeps keyboard focus inside the menu and renders in a portal.
Reviews are stored in Supabase, never in browser storage.
supabase/schema.sql in the SQL editor.SUPABASE_URL and SUPABASE_ANON_KEY (the public anon key; never use the service-role key here).pending. Row Level Security lets the public read only approved rows and
insert only pending rows. There are no update or delete policies.status to approved or rejected.Without these variables the review form shows that submissions are not enabled and nothing is sent. Spam protection: a hidden honeypot field, server-side validation, and an in-memory rate limit. The rate limit is per serverless instance, so add a shared limiter or CAPTCHA if you see abuse.
The form posts to /api/contact, which sends email through Resend. RESEND_API_KEY is required; CONTACT_TO_EMAIL and
CONTACT_FROM_EMAIL are optional (see the assistant notes above). Without the key the API answers 503 and the form says the message
was not sent.
A floating robot button (bottom right) opens a chat that answers questions about Cyrus only. On a visitor’s first page view it shows a greeting bubble for 3 seconds, then fades it out.
src/app/api/chat/route.ts calls OpenRouter. The key lives only in OPENROUTER_API_KEY and is never sent
to the browser. OPENROUTER_MODEL is optional (default openai/gpt-4o-mini).src/lib/assistant.ts. Add your own facts and exact answers in src/data/ai-knowledge.ts.src/data/site.ts.birthDate in src/data/ai-knowledge.ts is used on the server only to compute Cyrus’s current age. The assistant is
told to share the age, not the exact date. Delete that line and the age rule in src/lib/assistant.ts to stop sharing it./api/contact, so the email goes to
CONTACT_TO_EMAIL (default cyrusakacha@gmail.com) through Resend. Only you can be emailed, it is rate limited, and nothing is
reported as sent unless Resend accepted it. It needs RESEND_API_KEY. With no CONTACT_FROM_EMAIL, Resend’s test sender is used,
which delivers only to the email address of your own Resend account, so sign up to Resend with cyrusakacha@gmail.com
(or verify a domain and set CONTACT_FROM_EMAIL).See .env.example. All are optional for the site to build and run:
NEXT_PUBLIC_SITE_URL, SUPABASE_URL, SUPABASE_ANON_KEY, RESEND_API_KEY, CONTACT_TO_EMAIL, CONTACT_FROM_EMAIL, OPENROUTER_API_KEY, OPENROUTER_MODEL.
Never commit a real .env file.
NEXT_PUBLIC_SITE_URL to the final domain and redeploy so the sitemap and metadata use it.The toggle shows a sun while the dark theme is active and a moon while the light theme is active (the icon of
the theme you will switch to). The preference is saved in localStorage and applied before first paint.