Artifact version: 1.2.12
Classification: single-actor multilingual portfolio CMS
Canonical deployment target: Vercel + Supabase PostgreSQL/Auth
Primary public URL: https://michaelpiccirilli.it
Fallback platform URL: https://michaelpiccirilli.vercel.app
- Node.js
>= 20 - npm
>= 10 - Supabase project (Auth + Postgres)
- Resend API key (for
/api/contact)
Create .env.local in repo root (you can start from .env.example):
cp .env.example .env.localThen fill the required values:
SUPABASE_URL=...
SUPABASE_SECRET_KEY=...
DATABASE_URL=...
RESEND_API_KEY=...
CONTACT_FROM_EMAIL=onboarding@resend.dev
CONTACT_TO_EMAIL=your@email.tldOptional local debug flags:
DEV_API_WARMUP=true
DEV_API_DEBUG_LOGS=true
VITE_DEBUG_LOGS=truenpm ci
npm run dev:fastAlternative local runs:
npm run dev
npm run dev:api
npm run dev:vercelnpm run lint
npm run typecheck
npm run test:api
npm run build| Area | Status | Notes |
|---|---|---|
| Backend hardening | Done | validation, env checks, rate limits, error handling |
| Public homepage loading stability | Done | TODO point 15 formally closed |
| Admin health/observability | Partial | DB latency chart done, chart policy/UX refinement still open |
| UI/component automated tests | Open | API/repository tests available, UI test layer pending |
| Visual wow-factor polish | Open | UI effects/motion polish planned to improve first-impact for non-technical users |
| Distributed rate limiting | Done | Redis-backed mode active in production (RATE_LIMIT_MODE=redis) with resilient in-memory fallback |
| Surface | Path / Command | Purpose |
|---|---|---|
| Public app | /home |
portfolio public UI |
| Admin login | /login |
admin authentication entrypoint |
| Admin home | /admin |
admin operational dashboard |
| Admin tables | /admin/tables |
schema-driven CRUD console |
| API tests | npm run test:api |
handlers + repository integration suites |
| Quality gates | npm run lint && npm run typecheck && npm run build |
pre-push technical baseline |
| Deploy | Vercel + Supabase | serverless frontend/API + hosted Postgres/Auth |
flowchart LR
Browser["Browser / SPA"] --> Public["Public routes (/home)"]
Browser --> Admin["Admin routes (/login, /admin)"]
Browser --> ApiHome["API Home dispatcher (/api/home)"]
Browser --> ApiAdmin["API Admin dispatcher (/api/admin)"]
ApiHome --> Services["Service layer"]
ApiAdmin --> Services
Services --> Repos["Repository layer"]
Repos --> DB[("PostgreSQL via Drizzle")]
ApiAdmin --> Auth["Supabase Auth REST"]
GET /api/projects?lang=it -> api/home.ts dispatcher -> public-routes/projects handler -> publicContentService -> projectsRepository -> Drizzle query -> normalized JSON DTO -> React section unlock.
This repository implements a full-stack portfolio system whose main objective is not merely presentation, but controlled storage, publication, and administration of multilingual professional content. The system is modeled as a domain-specific content management artifact for a single actor: the public surface behaves like a highly curated portfolio site, while the backend and admin plane behave like a bounded CMS with explicit relational invariants.
The implementation adopts a two-plane architecture. The public read plane exposes deterministic, locale-aware JSON contracts consumed by a React/Vite single-page frontend. The admin control plane provides authenticated, schema-driven CRUD over selected relational tables, using metadata derived from a compile-time schema rather than ad hoc per-entity forms. Runtime persistence is handled through Drizzle ORM and PostgreSQL; authentication remains delegated to Supabase Auth over explicit HTTP calls, without shipping a browser database SDK into the admin runtime.
From a software-engineering standpoint, the repository should be read as an operationally deployable artifact with reproducible quality gates, typed boundaries, CI-backed verification, and a live roadmap of still-open work.
portfolio CMS, single-page application, TypeScript, React, Vite, Drizzle ORM, PostgreSQL, Supabase Auth, Vercel Functions, schema-driven admin, multilingual relational content
The project addresses the following design problem:
- portfolio content changes over time and should not require code edits for routine maintenance;
- the same conceptual entities must be rendered in more than one locale;
- presentation order is semantically meaningful and must be stored explicitly;
- public consumers should observe stable JSON contracts rather than query the database directly;
- the admin surface should remain server-owned and should not depend on a browser-managed database client.
These requirements rule out a purely static site architecture. The resulting system needs:
- a normalized relational model;
- a typed API layer;
- an authenticated administrative control plane;
- frontend readiness boundaries capable of partial loading without invalid transient UI states.
At the current main state, the repository provides the following verified properties:
- full TypeScript coverage across frontend, backend handlers, repositories, and operational tooling;
- stable backend layering of the form
api -> service -> repository -> database; - Drizzle-backed public repositories for profile, about, projects, experiences, and skills;
- a schema-driven admin plane whose generic CRUD is bounded by compile-time metadata under lib/admin;
- Supabase used only as hosted PostgreSQL/Auth infrastructure, not as the runtime query abstraction;
- a server-side contact flow under
/api/contact, backed by Resend, with request validation, rate limiting, honeypot filtering, and dedicated API tests; - GitHub Actions CI that runs
npm ci --no-fund --no-audit,npm run lint,npm run typecheck,npm run test:api, andnpm run build, then publishes a Markdown summary and log artifact; - Vercel-compatible serverless tuning, including explicit avoidance of the deprecated
req.queryruntime path under Node 24; - public progressive rendering with coordinated skeleton states and staged section unlocks.
flowchart LR
Browser[Browser / React SPA]
UI[UI composition layer]
API[Serverless API handlers]
Services[Service layer]
Repos[Repository layer]
Registry[Admin registry]
Drizzle[Drizzle ORM]
Auth[Supabase Auth REST]
DB[(PostgreSQL)]
Browser --> UI
Browser --> API
API --> Services
Services --> Repos
Services --> Registry
Repos --> Drizzle
Registry --> Drizzle
Drizzle --> DB
API --> Auth
The browser only consumes HTTP contracts and generated admin metadata. Persistence and authentication remain server-owned concerns.
| Area | Primary files | Role |
|---|---|---|
| Public API | api/home.ts + lib/services/public-routes/ | public endpoint dispatch behind a single serverless entrypoint |
| Admin API | api/admin.ts + lib/services/admin-routes/ | authenticated admin endpoints behind a single serverless entrypoint |
| Service layer | lib/services/ | orchestration between HTTP concerns and repositories |
| Database access | lib/db/ | Drizzle client, schema, repositories |
| Admin registry | lib/admin/ | table metadata, validators, grouping, editor semantics |
| HTTP utilities | lib/http/ | method guards, URL parsing, rate limiting, HTTP errors |
| Frontend UI | src/components/ | public and admin React components |
| CI / operations | .github/workflows/ | verification and deployment cleanup workflows |
The deployment routing strategy is intentionally simple. vercel.json serves filesystem assets first and then falls back to index.html, allowing the public site to remain a single-page application while still exposing serverless API endpoints.
Both public and admin APIs are intentionally consolidated to stay within Vercel Hobby function-count limits while preserving route-level separation in code:
- public routes are dispatched by api/home.ts, with modular handlers in lib/services/public-routes/;
- admin routes are dispatched by api/admin.ts, with modular handlers in lib/services/admin-routes/.
The database follows a normalized multilingual pattern:
- base tables describe structural entities;
*_i18ntables describe locale-specific text;order_indexencodes deterministic rendering order;slugprovides stable semantic identifiers where useful;- child relations model tags, images, and auxiliary ordered content;
- uniqueness constraints encode invariants in schema rather than inferring them from UI behavior.
profile+profile_i18n+social_linkshero_roles+hero_roles_i18nabout_interests+about_interests_i18nprojects+projects_i18n+project_tagsgithub_projects+github_projects_i18n+github_project_tags+github_project_imagesexperiences+experiences_i18neducation+education_i18ntech_categories+tech_categories_i18n+tech_itemsskill_categories+skill_categories_i18n+skill_items+skill_items_i18n
erDiagram
PROFILE ||--o{ PROFILE_I18N : localized_as
PROFILE ||--o{ SOCIAL_LINKS : owns
HERO_ROLES ||--o{ HERO_ROLES_I18N : localized_as
ABOUT_INTERESTS ||--o{ ABOUT_INTERESTS_I18N : localized_as
PROJECTS ||--o{ PROJECTS_I18N : localized_as
PROJECTS ||--o{ PROJECT_TAGS : tagged_by
GITHUB_PROJECTS ||--o{ GITHUB_PROJECTS_I18N : localized_as
GITHUB_PROJECTS ||--o{ GITHUB_PROJECT_TAGS : tagged_by
GITHUB_PROJECTS ||--o{ GITHUB_PROJECT_IMAGES : illustrated_by
EXPERIENCES ||--o{ EXPERIENCES_I18N : localized_as
EDUCATION ||--o{ EDUCATION_I18N : localized_as
TECH_CATEGORIES ||--o{ TECH_CATEGORIES_I18N : localized_as
TECH_CATEGORIES ||--o{ TECH_ITEMS : contains
SKILL_CATEGORIES ||--o{ SKILL_CATEGORIES_I18N : localized_as
SKILL_CATEGORIES ||--o{ SKILL_ITEMS : contains
SKILL_ITEMS ||--o{ SKILL_ITEMS_I18N : localized_as
From lib/db/schema.ts:
export const githubProjectImages = pgTable(
'github_project_images',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
githubProjectId: bigint('github_project_id', { mode: 'number' }).notNull(),
orderIndex: integer('order_index').notNull(),
imageUrl: text('image_url').notNull(),
altText: text('alt_text'),
},
(table) => [
unique('github_project_images_github_project_id_order_index_key').on(
table.githubProjectId,
table.orderIndex
),
]
)This fragment is representative of the broader modeling philosophy:
- order is first-class;
- media is modeled as a relation, not as a UI-only convenience field;
- data integrity is encoded in schema wherever possible.
| Endpoint | Purpose |
|---|---|
/api/profile |
hero profile, portrait metadata, socials, hero roles |
/api/about |
about copy and interests |
/api/projects |
portfolio projects, GitHub projects, tags, gallery images |
/api/skills |
tech stack, skill categories, localized skill items |
/api/experiences |
professional experience and education |
/api/health |
operational sanity check |
/api/contact |
validated contact submission with rate limiting, honeypot filtering, and Resend-backed delivery |
Each public endpoint is intentionally narrow:
- enforce method constraints;
- normalize locale from the WHATWG URL parser in lib/http/apiUtils.ts;
- check the process-local memory cache in lib/cache/memoryCache.ts;
- invoke lib/services/publicContentService.ts;
- serialize deterministic DTOs.
The handler is not responsible for reconstructing relational content. That responsibility is delegated to the repository layer.
The repositories reconstruct denormalized read models from normalized tables. For example, lib/db/repositories/projectsRepository.ts composes:
- base project rows;
- localized
*_i18nrows; - tag relations;
- featured GitHub projects;
- GitHub image galleries from
github_project_images.
The legacy github_projects.image_url field has been removed from the runtime model; github_project_images is now the canonical media relation for the GitHub project gallery flow.
The public UI was tuned toward monotonic rendering: each section transitions from placeholder to valid content without undefined intermediate states. Concretely, the current frontend provides:
- section-level skeletons rather than one global blocking spinner;
- synchronized hero portrait/text reveal in src/components/jsx/HeroTyping.tsx;
- staged public bootstrap in src/context/ContentContext.tsx, so sections unlock progressively instead of waiting on one monolithic batch;
- a GitHub screenshot viewer with click-triggered lightbox and gallery prewarming.
The admin uses Supabase Auth as authentication authority, but the browser never talks to Supabase directly. Instead:
- credentials are posted to api/admin.ts using the
/api/admin/loginroute; - the server forwards them to Supabase Auth REST;
- the server issues and verifies its own signed session cookie;
- the React admin consumes only the server-owned session endpoints.
This design keeps the browser-side admin runtime smaller and preserves a clear server-owned trust boundary.
The admin backend is mediated by metadata under lib/admin, especially lib/admin/registry.ts and the domain table modules in lib/admin/tables/. The registry encodes:
- allowed tables;
- labels and structural descriptions;
- table grouping and sidebar hierarchy;
- primary keys and default row shapes;
- field kinds (
text,textarea,number,url,email,checkbox,color,select); - relation selectors for foreign keys;
- normalization and validation rules.
The admin is therefore generic at the UX layer, but not schema-agnostic.
Admin CRUD operations are executed through metadata-constrained Drizzle operations:
- registry lookup;
- payload normalization;
- validation via lib/services/adminTableService.ts;
- resolved
select / insert / update / deletein lib/db/repositories/adminTableRepository.ts.
This provides a meaningful midpoint between a hardcoded backoffice and unconstrained dynamic SQL.
The frontend dashboard in src/components/jsx/AdminTable.tsx synthesizes its UI from registry metadata:
- grouped and nested sidebar navigation;
- typed editors and relation-backed selects;
- bilingual create flows for
*_i18ntables; - structural descriptions for low-semantic tables such as
hero_rolesandskill_items; - compact previews for links, images, icons, colors, and documents.
Operationally, the admin subtree is lazy-loaded as a dedicated asset family (assets/admin-*) through vite.config.js, keeping the public bundle smaller.
The runtime expects a local .env.local containing at least:
| Variable | Role |
|---|---|
SUPABASE_URL |
Supabase HTTP base URL used for auth REST calls |
SUPABASE_SECRET_KEY |
secret/service credential used for admin auth requests |
DATABASE_URL |
PostgreSQL DSN used by Drizzle, postgres, and DB tooling |
RATE_LIMIT_MODE |
rate limiter backend selector: memory (default) or redis |
UPSTASH_REDIS_REST_URL |
Upstash Redis REST endpoint (required only when RATE_LIMIT_MODE=redis) |
UPSTASH_REDIS_REST_TOKEN |
Upstash Redis REST token (required only when RATE_LIMIT_MODE=redis) |
RESEND_API_KEY |
Resend API key used by the contact endpoint |
CONTACT_FROM_EMAIL |
sender address for contact submissions (onboarding@resend.dev for test mode) |
CONTACT_TO_EMAIL |
destination inbox for contact submissions |
For Vercel production, the DSN should target the Supabase IPv4 transaction pooler.
When no owned domain is available yet, the initial contact-flow setup can use Resend test mode with onboarding@resend.dev as sender and the account mailbox as destination.
npm run dev
npm run dev:api
npm run dev:api:log
npm run dev:fast
npm run dev:vercelNote for local DX:
npm run dev:fastsupports optional API warmup viaDEV_API_WARMUP=true(default: disabled).npm run dev:apistarts the plaintsx watchruntime;npm run dev:api:logstarts the instrumented launcher that reports startup timing (launcher.start->launcher.end) and total ready elapsed in human-readable format.- dev API backend logs can be toggled with
DEV_API_DEBUG_LOGS=true|false. - frontend debug logs are disabled by default and can be enabled explicitly with
VITE_DEBUG_LOGS=true. - with warmup enabled, wait for
dev-api.warmup.readybefore evaluating first-load behavior on/home. - opening
/in local/dev now redirects immediately to/homefromindex.html, reducing pre-mount blank time.
Quality gates:
npm run lint
npm run typecheck
npm run test:api
npm run build
npm run formatDatabase tooling:
npm run db:generate
npm run db:migrate
npm run db:studioThe repository currently contains two operational workflows:
- CI test: installation, lint, typecheck, API tests, build, Markdown summary, log artifact;
- Cleanup Deployments: keeps only the latest production and preview deployments on GitHub.
The CI pipeline is intentionally small, but it establishes a reproducible minimum verification floor before deployment.
Current automated coverage is backend-focused:
- handler/API suites under
tests/api - repository/data suites under
tests/repositories - smoke checks on current API surface and session boundaries
Main command:
npm run test:apiTargeted examples:
npx vitest run tests/api/smoke.test.ts
npx vitest run tests/api/adminTableCrud.test.ts
npx vitest run tests/repositories/projectsRepository.test.tsPlanned next step:
- add frontend UI/component-level tests
- run them in a dedicated GitHub Action separated from the current backend CI lane
Release tracking follows a lightweight process:
- CHANGELOG.md accumulates ongoing work under Unreleased;
- stable checkpoints are recorded as semantic versions aligned with package.json;
- Git tags should mirror those versions using the form
vX.Y.Z.
The current implementation incorporates several pragmatic performance decisions:
- public/admin split so the admin subtree is excluded from the public entry bundle;
- optimized local project screenshots now stored canonically as
.webp; - lighter hero portrait and explicit head preload in index.html;
- coordinated hero skeleton and image reveal to avoid cache-path flicker or partial JPEG paint;
- process-local caching for public reads and switchable rate limiting (
memorydefault, optional Redis-backed distributed mode).
These choices are intentionally conservative: they improve runtime behavior without introducing a second infrastructure tier or a dedicated asset pipeline service.
The artifact is structurally mature, but not complete. Current limits include:
- cache remains process-local and distributed rate limiting is optional (requires explicit Redis configuration);
- the contact pipeline still uses the Resend test sender when no owned domain is available;
- no full admin upload flow exists for persistent media management;
- several roadmap items remain intentionally open, especially around coordinated tooling upgrades, content discoverability, final performance work, smoke tests, and admin upload UX.
The active roadmap is maintained in TODO.md and intentionally lists only open work.
- changelog: CHANGELOG.md
- current roadmap: TODO.md
- session log: SESSION.md
- API contract: docs/API_CONTRACT.md
- schema definition: lib/db/schema.ts
- public repository composition: lib/db/repositories/projectsRepository.ts
- admin registry: lib/admin/registry.ts
- CI workflow: .github/workflows/ci.yml